From 8033b0e5cbfe378474a34cecd709e43cb1788dfd Mon Sep 17 00:00:00 2001 From: David Hu Date: Wed, 11 Nov 2026 06:31:07 +0000 Subject: [PATCH 1/2] fix(usage): Past 24h shows the current hour The hourly window ran from now minus 24 hours to now, and the server slices hours from the window start. At 9:52 the buckets began at :52 and were labelled by the hour they started in, so the newest label was always the previous hour. Request the clock hour in progress plus the 23 before it, in the viewer's time zone, so each bucket is a clock hour. The web chart draws the segment into the hour in progress dotted. Co-Authored-By: Claude Fable 5.2 --- .../src/components/usage/UsageModelDialog.tsx | 2 - apps/web/src/components/usage/UsagePage.tsx | 8 ++-- .../components/usage/UsageProviderChart.tsx | 47 ++++++++++++------- packages/shared/src/usageFormat.test.ts | 20 ++++++-- packages/shared/src/usageFormat.ts | 17 ++++--- 5 files changed, 58 insertions(+), 36 deletions(-) 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..eb00fc31b26c1 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; @@ -195,26 +194,28 @@ function buildChart( metric: UsageChartMetric, providers: readonly UsageProviderKind[], loadingProviders: ReadonlySet, + lastInProgress: boolean, ) { const columns = buildPeriodColumns(periods, byPeriod, metric); const scale = chartScale(columns, loadingProviders); const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); const paths = providers.map((provider) => { const slot = PROVIDER_ORDER.indexOf(provider); - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * stepX, - y: valueToY(column.bands[slot]?.value ?? 0, scale.max), - })), - ), + const segments = smoothCurve( + columns.map((column, periodIndex) => ({ + x: periodIndex * stepX, + y: valueToY(column.bands[slot]?.value ?? 0, scale.max), + })), ); + // The segment into a period in progress is drawn dotted, since its value can still rise. + const settled = lastInProgress ? segments.slice(0, -1) : segments; return { provider, loading: loadingProviders.has(provider), total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0), - line, - area: areaPath(line), + line: curvePath(settled), + pendingLine: curvePath(segments.slice(settled.length)), + area: areaPath(curvePath(segments)), }; }); @@ -413,7 +414,6 @@ export function UsageProviderChart({ hours, hourly, metric, - referenceTime, resolution, timeZone, hour12, @@ -423,12 +423,15 @@ 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, metric, providers, loadingProviders, + latestHour !== undefined, ); const toY = (value: number) => valueToY(value, scale.max); @@ -438,8 +441,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 ( @@ -495,16 +498,24 @@ export function UsageProviderChart({ fillOpacity={0.12} /> ))} - {paths.map(({ provider, loading, line }) => ( - ( + + > + + {pendingLine === "" ? null : ( + + )} + ))}
diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index c847f323a2065..6eaeca8572ba2 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 1c6ba7b8fd75a..7c0921e9822cb 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -163,7 +163,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)) @@ -269,17 +269,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. From 4556316471877825b4351fa76ef1e34cec3244bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 19:44:17 +0000 Subject: [PATCH 2/2] fix(web): keep the usage chart line solid through the current hour The dotted final segment changes how the chart looks beyond the label fix, so it is left for a separate decision. Co-Authored-By: Claude Opus 5.5 --- .../components/usage/UsageProviderChart.tsx | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index eb00fc31b26c1..702ea649556df 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -194,28 +194,26 @@ function buildChart( metric: UsageChartMetric, providers: readonly UsageProviderKind[], loadingProviders: ReadonlySet, - lastInProgress: boolean, ) { const columns = buildPeriodColumns(periods, byPeriod, metric); const scale = chartScale(columns, loadingProviders); const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); const paths = providers.map((provider) => { const slot = PROVIDER_ORDER.indexOf(provider); - const segments = smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * stepX, - y: valueToY(column.bands[slot]?.value ?? 0, scale.max), - })), + const line = curvePath( + smoothCurve( + columns.map((column, periodIndex) => ({ + x: periodIndex * stepX, + y: valueToY(column.bands[slot]?.value ?? 0, scale.max), + })), + ), ); - // The segment into a period in progress is drawn dotted, since its value can still rise. - const settled = lastInProgress ? segments.slice(0, -1) : segments; return { provider, loading: loadingProviders.has(provider), total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0), - line: curvePath(settled), - pendingLine: curvePath(segments.slice(settled.length)), - area: areaPath(curvePath(segments)), + line, + area: areaPath(line), }; }); @@ -431,7 +429,6 @@ export function UsageProviderChart({ metric, providers, loadingProviders, - latestHour !== undefined, ); const toY = (value: number) => valueToY(value, scale.max); @@ -498,24 +495,16 @@ export function UsageProviderChart({ fillOpacity={0.12} /> ))} - {paths.map(({ provider, loading, line, pendingLine }) => ( - ( + - - {pendingLine === "" ? null : ( - - )} - + vectorEffect="non-scaling-stroke" + /> ))}