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.