@@ -24,15 +24,23 @@ import { useI18n } from "~/context/i18n"
2424
2525Chart . register ( BarController , BarElement , CategoryScale , LinearScale , Tooltip , Legend )
2626
27- async function getCosts ( workspaceID : string , year : number , month : number ) {
27+ async function getCosts ( workspaceID : string , year : number , month : number , tzOffset : string ) {
2828 "use server"
2929 return withActor ( async ( ) => {
30- const startDate = new Date ( year , month , 1 )
31- const endDate = new Date ( year , month + 1 , 1 )
30+ const timezoneOffset = ( ( ) => {
31+ const m = / ^ ( [ + - ] ) ( \d { 2 } ) : ( \d { 2 } ) $ / . exec ( tzOffset )
32+ if ( ! m ) return 0
33+ const sign = m [ 1 ] === "-" ? - 1 : 1
34+ return sign * ( Number ( m [ 2 ] ) * 60 + Number ( m [ 3 ] ) ) * 60_000
35+ } ) ( )
36+
37+ const monthStartUTC = new Date ( Date . UTC ( year , month , 1 , 0 , 0 , 0 ) - timezoneOffset )
38+ const monthEndUTC = new Date ( Date . UTC ( year , month + 1 , 1 , 0 , 0 , 0 ) - timezoneOffset )
39+ const dateExpr = sql < string > `DATE(CONVERT_TZ(${ UsageTable . timeCreated } , '+00:00', ${ tzOffset } ))`
3240 const usageData = await Database . use ( ( tx ) =>
3341 tx
3442 . select ( {
35- date : sql < string > `DATE( ${ UsageTable . timeCreated } )` ,
43+ date : dateExpr ,
3644 model : UsageTable . model ,
3745 totalCost : sum ( UsageTable . cost ) ,
3846 keyId : UsageTable . keyID ,
@@ -42,16 +50,11 @@ async function getCosts(workspaceID: string, year: number, month: number) {
4250 . where (
4351 and (
4452 eq ( UsageTable . workspaceID , workspaceID ) ,
45- gte ( UsageTable . timeCreated , startDate ) ,
46- lt ( UsageTable . timeCreated , endDate ) ,
53+ gte ( UsageTable . timeCreated , monthStartUTC ) ,
54+ lt ( UsageTable . timeCreated , monthEndUTC ) ,
4755 ) ,
4856 )
49- . groupBy (
50- sql `DATE(${ UsageTable . timeCreated } )` ,
51- UsageTable . model ,
52- UsageTable . keyID ,
53- sql `JSON_EXTRACT(${ UsageTable . enrichment } , '$.plan')` ,
54- )
57+ . groupBy ( dateExpr , UsageTable . model , UsageTable . keyID , sql `JSON_EXTRACT(${ UsageTable . enrichment } , '$.plan')` )
5558 . then ( ( x ) =>
5659 x . map ( ( r ) => ( {
5760 ...r ,
@@ -125,15 +128,45 @@ function getModelColor(model: string): string {
125128}
126129
127130function formatDateLabel ( dateStr : string ) : string {
128- const date = new Date ( )
129- const [ y , m , d ] = dateStr . split ( "-" ) . map ( Number )
130- date . setFullYear ( y )
131- date . setMonth ( m - 1 )
132- date . setDate ( d )
133- date . setHours ( 0 , 0 , 0 , 0 )
134- const month = date . toLocaleDateString ( undefined , { month : "short" } )
135- const day = date . getUTCDate ( ) . toString ( ) . padStart ( 2 , "0" )
136- return `${ month } ${ day } `
131+ const [ , m , d ] = dateStr . split ( "-" ) . map ( Number )
132+ const month = new Date ( 2000 , m - 1 , 1 ) . toLocaleDateString ( undefined , { month : "short" } )
133+ return `${ month } ${ d . toString ( ) . padStart ( 2 , "0" ) } `
134+ }
135+
136+ // Compute the UTC offset (in MySQL CONVERT_TZ format like "+05:30") for the
137+ // given IANA timezone at the given instant. Honors DST.
138+ function getTimezoneOffset ( timezone : string , at : Date ) : string {
139+ const parts = new Intl . DateTimeFormat ( "en-US" , {
140+ timeZone : timezone ,
141+ hourCycle : "h23" ,
142+ year : "numeric" ,
143+ month : "2-digit" ,
144+ day : "2-digit" ,
145+ hour : "2-digit" ,
146+ minute : "2-digit" ,
147+ second : "2-digit" ,
148+ } )
149+ . formatToParts ( at )
150+ . reduce < Record < string , string > > ( ( acc , p ) => {
151+ if ( p . type !== "literal" ) acc [ p . type ] = p . value
152+ return acc
153+ } , { } )
154+ const asUTC = Date . UTC (
155+ Number ( parts . year ) ,
156+ Number ( parts . month ) - 1 ,
157+ Number ( parts . day ) ,
158+ Number ( parts . hour ) ,
159+ Number ( parts . minute ) ,
160+ Number ( parts . second ) ,
161+ )
162+ const diffMinutes = Math . round ( ( asUTC - at . getTime ( ) ) / 60_000 )
163+ const sign = diffMinutes < 0 ? "-" : "+"
164+ const abs = Math . abs ( diffMinutes )
165+ const hh = Math . floor ( abs / 60 )
166+ . toString ( )
167+ . padStart ( 2 , "0" )
168+ const mm = ( abs % 60 ) . toString ( ) . padStart ( 2 , "0" )
169+ return `${ sign } ${ hh } :${ mm } `
137170}
138171
139172function addOpacityToColor ( color : string , opacity : number ) : string {
@@ -152,6 +185,7 @@ export function GraphSection() {
152185 let chartInstance : Chart | undefined
153186 const params = useParams ( )
154187 const i18n = useI18n ( )
188+ const timezone = Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone
155189 const now = new Date ( )
156190 const [ store , setStore ] = createStore ( {
157191 data : null as Awaited < ReturnType < typeof getCosts > > | null ,
@@ -185,10 +219,13 @@ export function GraphSection() {
185219 } )
186220
187221 const getDates = createMemo ( ( ) => {
188- const daysInMonth = new Date ( store . year , store . month + 1 , 0 ) . getDate ( )
222+ // Number of days in the month is independent of timezone.
223+ const daysInMonth = new Date ( Date . UTC ( store . year , store . month + 1 , 0 ) ) . getUTCDate ( )
224+ const yyyy = store . year . toString ( ) . padStart ( 4 , "0" )
225+ const mm = ( store . month + 1 ) . toString ( ) . padStart ( 2 , "0" )
189226 return Array . from ( { length : daysInMonth } , ( _ , i ) => {
190- const date = new Date ( store . year , store . month , i + 1 )
191- return date . toISOString ( ) . split ( "T" ) [ 0 ]
227+ const dd = ( i + 1 ) . toString ( ) . padStart ( 2 , "0" )
228+ return ` ${ yyyy } - ${ mm } - ${ dd } `
192229 } )
193230 } )
194231
@@ -415,7 +452,11 @@ export function GraphSection() {
415452 } )
416453
417454 createEffect ( async ( ) => {
418- const data = await getCosts ( params . id ! , store . year , store . month )
455+ // Compute the offset for mid-month so DST transitions don't bias to the
456+ // wrong side.
457+ const midMonth = new Date ( Date . UTC ( store . year , store . month , 15 , 12 , 0 , 0 ) )
458+ const tzOffset = getTimezoneOffset ( timezone , midMonth )
459+ const data = await getCosts ( params . id ! , store . year , store . month , tzOffset )
419460 setStore ( { data } )
420461 } )
421462
0 commit comments