diff --git a/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx index d6b8873858..b5033f25f6 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx @@ -1,4 +1,4 @@ -import { and, Database, eq, gte, inArray, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js" import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" @@ -10,7 +10,6 @@ import { withActor } from "~/context/auth.withActor" import { Dropdown } from "~/component/dropdown" import { IconChevronLeft, IconChevronRight } from "~/component/icon" import styles from "./graph-section.module.css" -import { localDateLabel, localDateUTC } from "./usage-time" import { Chart, BarController, @@ -25,53 +24,41 @@ import { useI18n } from "~/context/i18n" Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend) -async function getCosts(workspaceID: string, year: number, month: number, timezone: string) { +async function getCosts(workspaceID: string, year: number, month: number, tzOffset: string) { "use server" return withActor(async () => { - const monthEnd = localDateLabel(year, month + 1, 1) - const planExpr = sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')` - const windows: Array<{ date: string; start: Date; end: Date }> = [] - for (let day = 1; ; day++) { - const date = localDateLabel(year, month, day) - if (date >= monthEnd) break - windows.push({ - date, - start: localDateUTC(timezone, year, month, day), - end: localDateUTC(timezone, year, month, day + 1), - }) - } - - const first = windows[0]! - const last = windows[windows.length - 1]! - const dateExpr = sql`case ${sql.join( - windows.map( - (window) => - sql`when ${UsageTable.timeCreated} >= ${window.start} and ${UsageTable.timeCreated} < ${window.end} then ${window.date}`, - ), - sql` `, - )} end` + const timezoneOffset = (() => { + const m = /^([+-])(\d{2}):(\d{2})$/.exec(tzOffset) + if (!m) return 0 + const sign = m[1] === "-" ? -1 : 1 + return sign * (Number(m[2]) * 60 + Number(m[3])) * 60_000 + })() + + const monthStartUTC = new Date(Date.UTC(year, month, 1, 0, 0, 0) - timezoneOffset) + const monthEndUTC = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0) - timezoneOffset) + const dateExpr = sql`DATE(CONVERT_TZ(${UsageTable.timeCreated}, '+00:00', ${tzOffset}))` const usageData = await Database.use((tx) => tx .select({ date: dateExpr, model: UsageTable.model, - totalCost: sql`coalesce(sum(${UsageTable.cost}), 0)`, + totalCost: sum(UsageTable.cost), keyId: UsageTable.keyID, - plan: planExpr, + plan: sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, }) .from(UsageTable) .where( and( eq(UsageTable.workspaceID, workspaceID), - gte(UsageTable.timeCreated, first.start), - lt(UsageTable.timeCreated, last.end), + gte(UsageTable.timeCreated, monthStartUTC), + lt(UsageTable.timeCreated, monthEndUTC), ), ) - .groupBy(dateExpr, UsageTable.model, UsageTable.keyID, planExpr) - .then((rows) => - rows.map((r) => ({ + .groupBy(dateExpr, UsageTable.model, UsageTable.keyID, sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`) + .then((x) => + x.map((r) => ({ ...r, - totalCost: Number(r.totalCost ?? 0), + totalCost: r.totalCost ? parseInt(r.totalCost) : 0, plan: r.plan as "sub" | "lite" | "byok" | null, })), ), @@ -146,6 +133,42 @@ function formatDateLabel(dateStr: string): string { return `${month} ${d.toString().padStart(2, "0")}` } +// Compute the UTC offset (in MySQL CONVERT_TZ format like "+05:30") for the +// given IANA timezone at the given instant. Honors DST. +function getTimezoneOffset(timezone: string, at: Date): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + .formatToParts(at) + .reduce>((acc, p) => { + if (p.type !== "literal") acc[p.type] = p.value + return acc + }, {}) + const asUTC = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second), + ) + const diffMinutes = Math.round((asUTC - at.getTime()) / 60_000) + const sign = diffMinutes < 0 ? "-" : "+" + const abs = Math.abs(diffMinutes) + const hh = Math.floor(abs / 60) + .toString() + .padStart(2, "0") + const mm = (abs % 60).toString().padStart(2, "0") + return `${sign}${hh}:${mm}` +} + function addOpacityToColor(color: string, opacity: number): string { if (color.startsWith("#")) { const r = parseInt(color.slice(1, 3), 16) @@ -429,7 +452,11 @@ export function GraphSection() { }) createEffect(async () => { - const data = await getCosts(params.id!, store.year, store.month, timezone) + // Compute the offset for mid-month so DST transitions don't bias to the + // wrong side. + const midMonth = new Date(Date.UTC(store.year, store.month, 15, 12, 0, 0)) + const tzOffset = getTimezoneOffset(timezone, midMonth) + const data = await getCosts(params.id!, store.year, store.month, tzOffset) setStore({ data }) }) diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-time.ts b/packages/console/app/src/routes/workspace/[id]/usage/usage-time.ts deleted file mode 100644 index b6e9ad8808..0000000000 --- a/packages/console/app/src/routes/workspace/[id]/usage/usage-time.ts +++ /dev/null @@ -1,46 +0,0 @@ -const HOUR = 60 * 60 * 1000 - -export function localDateLabel(year: number, month: number, day: number): string { - const date = new Date(Date.UTC(year, month, day)) - return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}` -} - -export function localDateUTC(timezone: string, year: number, month: number, day: number): Date { - const target = localDateLabel(year, month, day) - const format = createLocalDateFormatter(timezone) - const noon = Date.UTC(year, month, day, 12) - const rangeStart = noon - 48 * HOUR - const rangeEnd = noon + 48 * HOUR - - for (let time = rangeStart; time <= rangeEnd; time += HOUR) { - if (format(new Date(time)) !== target) continue - - let low = time - HOUR - let high = time - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (format(new Date(mid)) < target) low = mid + 1 - else high = mid - } - return new Date(low) - } - - throw new Error(`Could not find local date ${target} in ${timezone}`) -} - -function createLocalDateFormatter(timezone: string) { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - return (at: Date) => { - const parts = formatter.formatToParts(at).reduce>((acc, p) => { - if (p.type !== "literal") acc[p.type] = p.value - return acc - }, {}) - return `${parts.year}-${parts.month}-${parts.day}` - } -} diff --git a/packages/console/app/test/usage-time.test.ts b/packages/console/app/test/usage-time.test.ts deleted file mode 100644 index 1f446bc4cd..0000000000 --- a/packages/console/app/test/usage-time.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { localDateLabel, localDateUTC } from "../src/routes/workspace/[id]/usage/usage-time" - -function localDate(timezone: string, at: Date): string { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - .formatToParts(at) - .reduce>((acc, p) => { - if (p.type !== "literal") acc[p.type] = p.value - return acc - }, {}) - return `${parts.year}-${parts.month}-${parts.day}` -} - -describe("localDateLabel", () => { - test("normalizes month overflow", () => { - expect(localDateLabel(2026, 12, 1)).toBe("2027-01-01") - }) -}) - -describe("localDateUTC", () => { - test("finds the first instant of a normal local day", () => { - const start = localDateUTC("America/New_York", 2026, 0, 15) - - expect(start.toISOString()).toBe("2026-01-15T05:00:00.000Z") - expect(localDate("America/New_York", new Date(start.getTime() - 1))).toBe("2026-01-14") - expect(localDate("America/New_York", start)).toBe("2026-01-15") - }) - - test("finds the first instant when DST skips local midnight", () => { - const start = localDateUTC("Africa/Cairo", 2024, 3, 26) - - expect(start.toISOString()).toBe("2024-04-25T22:00:00.000Z") - expect(localDate("Africa/Cairo", new Date(start.getTime() - 1))).toBe("2024-04-25") - expect(localDate("Africa/Cairo", start)).toBe("2024-04-26") - }) -})