Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/web/src/components/usage/UsageModelDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 @4xl/usage-header:flex">
Expand Down Expand Up @@ -562,7 +564,6 @@ export function UsagePage() {
hours,
resolution: shownHourly ? "hour" : "day",
timeZone: shownWindow.timeZone,
referenceTime: shownWindow.untilTime,
hour12,
}}
onSetPrice={(model) => setPriceDialog({ model })}
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/usage/UsageProviderChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -413,7 +412,6 @@ export function UsageProviderChart({
hours,
hourly,
metric,
referenceTime,
resolution,
timeZone,
hour12,
Expand All @@ -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,
Expand All @@ -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 (
Expand Down
20 changes: 15 additions & 5 deletions packages/shared/src/usageFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
17 changes: 10 additions & 7 deletions packages/shared/src/usageFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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);
Comment thread
davidhu2000 marked this conversation as resolved.
Resolved
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.
Expand Down
Loading