Skip to content

Commit b146ce3

Browse files
fwangoleksii-honchar
authored andcommitted
sync
1 parent 835810a commit b146ce3

1 file changed

Lines changed: 66 additions & 25 deletions

File tree

packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,23 @@ import { useI18n } from "~/context/i18n"
2424

2525
Chart.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

127130
function 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

139172
function 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

Comments
 (0)