diff --git a/apps/web/src/components/usage/UsageModelDialog.tsx b/apps/web/src/components/usage/UsageModelDialog.tsx index 0fb6b89ce73a4..9d8981a293d18 100644 --- a/apps/web/src/components/usage/UsageModelDialog.tsx +++ b/apps/web/src/components/usage/UsageModelDialog.tsx @@ -29,7 +29,6 @@ export interface UsageChartWindow { readonly hours: readonly string[]; readonly resolution: "day" | "hour"; readonly timeZone: string; - readonly referenceTime: string | undefined; readonly hour12: boolean; } @@ -105,7 +104,6 @@ export function UsageModelDialog({ hours={chartWindow.hours} hourly={usage.hourly} metric={costUnknown ? "tokens" : metric} - referenceTime={chartWindow.referenceTime} resolution={chartWindow.resolution} timeZone={chartWindow.timeZone} hour12={chartWindow.hour12} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 8bc8a93a43d69..123eff8a2d0d2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -352,9 +352,11 @@ export function UsagePage() { (presentation.serverConfig?.providers ?? []).some(usesChatGptSharing), ); + // Like the day label, the hour label names the first and last periods shown. + const lastHour = hours.at(-1); const windowLabel = - shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined - ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone, hour12)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone, hour12)}` + shownHourly && shownWindow.sinceTime !== undefined && lastHour !== undefined + ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone, hour12)} to ${formatDateTimeShort(lastHour, shownWindow.timeZone, hour12)}` : `${formatDayShort(shownWindow.sinceDay)} to ${formatDayShort(shownWindow.untilDay)}`; const topbarContent = (
@@ -562,7 +564,6 @@ export function UsagePage() { hours, resolution: shownHourly ? "hour" : "day", timeZone: shownWindow.timeZone, - referenceTime: shownWindow.untilTime, hour12, }} onSetPrice={(model) => setPriceDialog({ model })} @@ -773,7 +774,6 @@ function UsageOverview({ hours={hours} hourly={merged.hourly} metric={metric} - referenceTime={shownWindow.untilTime} resolution={shownHourly ? "hour" : "day"} timeZone={shownWindow.timeZone} hour12={hour12} diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 1745be4df897b..702ea649556df 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -30,7 +30,6 @@ interface UsageProviderChartProps { readonly hours: readonly string[]; readonly hourly: readonly HourlyTotals[]; readonly metric: UsageChartMetric; - readonly referenceTime: string | undefined; readonly resolution: "day" | "hour"; readonly timeZone: string; readonly hour12: boolean; @@ -413,7 +412,6 @@ export function UsageProviderChart({ hours, hourly, metric, - referenceTime, resolution, timeZone, hour12, @@ -423,6 +421,8 @@ export function UsageProviderChart({ const byPeriod = new Map( totals.map((entry) => ["hourStart" in entry ? entry.hourStart : entry.day, entry]), ); + // An hourly window ends with the hour in progress. + const latestHour = resolution === "hour" ? periods.at(-1) : undefined; const { columns, scale, paths, stepX } = buildChart( periods, byPeriod, @@ -438,8 +438,8 @@ export function UsageProviderChart({ const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone, hour12) : formatDayShort(period); const formatTooltipPeriod = (period: string) => - resolution === "hour" && referenceTime !== undefined - ? formatRelativeHourShort(period, referenceTime, timeZone, hour12) + latestHour !== undefined + ? formatRelativeHourShort(period, latestHour, timeZone, hour12) : formatPeriod(period); return ( diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 79477fc1ed4d4..5f439b6a23b55 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -152,12 +152,22 @@ describe("hourly usage formatting", () => { ); }); - it("builds an exact minute-aligned 24-hour request", () => { - const window = makeWindow(1, new Date("2026-08-11T12:37:42.123Z"), "hour"); + it("builds a 24-hour request on the viewer's clock hours", () => { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone: "Asia/Kolkata" }); + + try { + // 18:07 in Kolkata, where hours start on the UTC half hour. + const window = makeWindow(1, new Date("2026-08-11T12:37:42.123Z"), "hour"); - expect(window.resolution).toBe("hour"); - expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); - expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); + expect(window.resolution).toBe("hour"); + expect(window.sinceTime).toBe("2026-08-10T13:30:00.000Z"); + expect(window.untilTime).toBe("2026-08-11T13:30:00.000Z"); + } finally { + resolvedOptions.mockRestore(); + } }); it("degrades an unknown resolved zone to UTC instead of crashing", () => { diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index f2a2d9c73e764..5de77352ba4c4 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -168,7 +168,7 @@ function hourOptions(hour12: boolean): Intl.DateTimeFormatOptions { function formatHour(formatter: Intl.DateTimeFormat, date: Date, hour12: boolean): string { if (hour12) return formatter.format(date); - // Rolling buckets can start between whole hours, but their labels always name the hour. + // Labels name the hour; a bucket starts off it only across a half-hour DST shift. return formatter .formatToParts(date) .map((part) => (part.type === "hour" ? `${part.value}:00` : part.value)) @@ -274,17 +274,20 @@ export function makeWindow( } const untilDay = format.format(now); if (resolution === "hour") { - const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000; - const sinceTimeMs = untilTimeMs - 24 * HOUR_MS; - const sinceTime = new Date(sinceTimeMs); - const untilTime = new Date(untilTimeMs); + // Clock hours in the viewer's zone: the hour in progress and the 23 before it. Zone offsets + // are whole minutes, so stepping back the local minute lands on the hour. + const minute = Number( + new Intl.DateTimeFormat("en-US", { timeZone, minute: "numeric" }).format(now), + ); + const hourStartMs = (Math.floor(now.getTime() / 60_000) - minute) * 60_000; + const sinceTime = new Date(hourStartMs - 23 * HOUR_MS); return { sinceDay: UsageDay.make(format.format(sinceTime)), - untilDay: UsageDay.make(format.format(untilTime)), + untilDay: UsageDay.make(untilDay), timeZone, resolution, sinceTime: sinceTime.toISOString(), - untilTime: untilTime.toISOString(), + untilTime: new Date(hourStartMs + HOUR_MS).toISOString(), }; } // Use calendar arithmetic in UTC; fixed millisecond subtraction crosses DST days incorrectly.