From e20e946e2cacdcbdf54f351efeda10e54b874989 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 11 Sep 2026 12:46:12 +1000 Subject: [PATCH 1/2] feat(web): zoom usage charts and select custom date ranges Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/usage/UsagePage.test.tsx | 50 +++- apps/web/src/components/usage/UsagePage.tsx | 159 ++++++++++++- .../UsageProviderChart.interaction.test.tsx | 81 +++++++ .../usage/UsageProviderChart.test.ts | 71 +++++- .../components/usage/UsageProviderChart.tsx | 216 ++++++++++++++++-- docs/user/usage.md | 4 + packages/shared/src/usageFormat.test.ts | 42 ++++ packages/shared/src/usageFormat.ts | 60 ++++- 8 files changed, 633 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 0743b91f6edf..14e6277ae59a 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -4,9 +4,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ + customWindow: false, + zoomToDays: undefined as ((since: string, until: string) => void) | undefined, + resetZoom: undefined as (() => void) | undefined, useUsage: vi.fn(), metric: "cost" as "cost" | "tokens" | "limits", breakdown: "time" as "model" | "time", + setWindowSelection: vi.fn(), })); vi.mock("react", async (importOriginal) => { @@ -18,7 +22,8 @@ vi.mock("react", async (importOriginal) => { ? { metric: testState.metric, windowDays: 30 } : typeof initial === "function" ? { - days: 1, + days: testState.customWindow ? 30 : 1, + custom: testState.customWindow, window: { sinceDay: "2026-08-10", untilDay: "2026-08-11", @@ -33,7 +38,9 @@ vi.mock("react", async (importOriginal) => { : initial === "model" ? testState.breakdown : initial, - vi.fn(), + typeof initial === "function" && initial !== readUsagePagePreferences + ? testState.setWindowSelection + : vi.fn(), ]), }; }); @@ -41,6 +48,7 @@ vi.mock("react", async (importOriginal) => { vi.mock("../../env", () => ({ isElectron: false })); vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/input", () => ({ Input: "input" })); vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); vi.mock("../ui/select", () => ({ Select: "div", @@ -58,7 +66,16 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ })); vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); -vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsageProviderChart", () => ({ + UsageProviderChart: (props: { + onZoomToDays?: (since: string, until: string) => void; + onResetZoom?: () => void; + }) => { + testState.zoomToDays = props.onZoomToDays; + testState.resetZoom = props.onResetZoom; + return
; + }, +})); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); vi.mock("./usageProviders", async (importOriginal) => { const actual = await importOriginal(); @@ -140,8 +157,12 @@ const environments = [ ]; beforeEach(() => { + testState.customWindow = false; + testState.zoomToDays = undefined; + testState.resetZoom = undefined; testState.metric = "cost"; testState.breakdown = "time"; + testState.setWindowSelection.mockReset(); testState.useUsage.mockReturnValue({ merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), @@ -229,3 +250,26 @@ describe("UsagePage model breakdown", () => { ]); }); }); + +it("restores the original custom window after repeated chart zooms", () => { + testState.customWindow = true; + renderToStaticMarkup(); + expect(testState.zoomToDays).toBeTypeOf("function"); + testState.zoomToDays?.("2026-08-10", "2026-08-10"); + testState.zoomToDays?.("2026-08-11", "2026-08-11"); + testState.resetZoom?.(); + expect(testState.setWindowSelection).toHaveBeenLastCalledWith( + expect.objectContaining({ + custom: true, + window: expect.objectContaining({ sinceDay: "2026-08-10", untilDay: "2026-08-11" }), + }), + ); +}); + +it("keeps an unzoomed custom range when the plot is double-clicked", () => { + testState.customWindow = true; + renderToStaticMarkup(); + expect(testState.resetZoom).toBeTypeOf("function"); + testState.resetZoom?.(); + expect(testState.setWindowSelection).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 21970c675596..a39d7917405e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -27,6 +27,7 @@ import { serverEnvironment } from "../../state/server"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { useAtomCommand } from "../../state/use-atom-command"; import { + compareUsageDays, enumerateDays, enumerateHourStarts, formatCount, @@ -36,9 +37,12 @@ import { formatPercent, formatTokens, formatUsd, + makeCustomWindow, makeWindow, } from "@t3tools/shared/usageFormat"; +import { useCommitOnBlur } from "../../hooks/useCommitOnBlur"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { Menu, MenuCheckboxItem, @@ -95,12 +99,14 @@ export function UsagePage() { const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ days: preferences.windowDays, + custom: false, window: makeWindow( preferences.windowDays, undefined, preferences.windowDays === 1 ? "hour" : "day", ), })); + const preZoomSelection = useRef(null); const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); @@ -108,8 +114,8 @@ export function UsagePage() { const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + const { days: windowDays, custom: isCustomWindow, window } = windowSelection; + const isPast24Hours = !isCustomWindow && windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( window, selectedEnvironmentIds, @@ -150,14 +156,39 @@ export function UsagePage() { const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; + preZoomSelection.current = null; const nextPreferences = { metric, windowDays: days }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); setWindowSelection({ days, + custom: false, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectCustomWindow = (sinceDay: string, untilDay: string) => { + preZoomSelection.current = null; + setWindowSelection({ + days: windowDays, + custom: true, + window: makeCustomWindow(sinceDay, untilDay), + }); + }; + const zoomToDays = (sinceDay: string, untilDay: string) => { + preZoomSelection.current ??= windowSelection; + setWindowSelection({ + days: windowDays, + custom: true, + window: makeCustomWindow(sinceDay, untilDay), + }); + }; + const resetZoom = () => { + const original = preZoomSelection.current; + if (original === null) return; + preZoomSelection.current = null; + if (original.custom) setWindowSelection(original); + else selectWindow(original.days); + }; const selectMetric = (nextMetric: UsageMetric) => { const nextPreferences = { metric: nextMetric, windowDays }; setPreferences(nextPreferences); @@ -182,14 +213,16 @@ export function UsagePage() { }); return; } - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + const nextWindow = isCustomWindow + ? window + : makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ days: windowDays, custom: false, window: nextWindow }); } refreshingRef.current = true; setIsRefreshing(true); @@ -243,12 +276,18 @@ export function UsagePage() { ))} + {/* The period does not apply to Limits, so it stays in place but disabled; unmounting it shifted the metric toggle ~300px. */} { const value = next[0]; @@ -298,9 +337,11 @@ export function UsagePage() { + to + +
+ ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 622d73d13844..000c20a3ebf7 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPeriodColumns, niceScale } from "./UsageProviderChart"; +import { + brushSelection, + buildPeriodColumns, + chartLabelIndices, + niceScale, + periodIndexAt, + spanSinglePeriodPoints, +} from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; describe("niceScale", () => { @@ -135,3 +142,65 @@ describe("hourly chart columns", () => { ).toEqual([0, 4, 0]); }); }); + +describe("brushSelection", () => { + const days = ["2026-08-01", "2026-08-02", "2026-08-03", "2026-08-04"]; + + it("returns inclusive bounds for a forward drag", () => { + expect(brushSelection(days, 1, 3)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("normalises a backward drag", () => { + expect(brushSelection(days, 3, 1)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("treats a plain click as no selection", () => { + expect(brushSelection(days, 2, 2)).toBeNull(); + }); + + it("rejects endpoints outside the day list", () => { + expect(brushSelection(days, 0, 9)).toBeNull(); + }); +}); + +describe("periodIndexAt", () => { + it("clamps a captured pointer to either chart edge", () => { + expect(periodIndexAt(-50, 100, 400, 5)).toBe(0); + expect(periodIndexAt(750, 100, 400, 5)).toBe(4); + }); +}); + +describe("spanSinglePeriodPoints", () => { + it("repeats one point across the chart width", () => { + expect(spanSinglePeriodPoints([{ x: 0, y: 42 }])).toEqual([ + { x: 0, y: 42 }, + { x: 960, y: 42 }, + ]); + }); + + it("leaves multi-period points unchanged", () => { + const points = [ + { x: 0, y: 42 }, + { x: 960, y: 12 }, + ]; + + expect(spanSinglePeriodPoints(points)).toBe(points); + }); +}); + +describe("chartLabelIndices", () => { + it("deduplicates labels for one- and two-period windows", () => { + expect(chartLabelIndices(1)).toEqual([0]); + expect(chartLabelIndices(2)).toEqual([0, 1]); + }); + + it("keeps left, middle, and right labels for wider windows", () => { + expect(chartLabelIndices(5)).toEqual([0, 2, 4]); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 4a66349ddfa5..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -2,6 +2,8 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; + +import { cn } from "../../lib/utils"; import { formatDayShort, formatHourShort, @@ -19,6 +21,13 @@ const PLOT_TOP = 8; export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { + /** + * Present only when the window can zoom (daily resolution). Receives the + * inclusive day bounds of a completed drag selection. + */ + readonly onZoomToDays?: (sinceDay: string, untilDay: string) => void; + /** Restores the preset window on double-click. */ + readonly onResetZoom?: () => void; readonly providers: readonly UsageProviderKind[]; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; @@ -44,6 +53,18 @@ interface Point { readonly y: number; } +/** Gives a one-period daily window enough horizontal span to draw a path. */ +export function spanSinglePeriodPoints(points: readonly Point[]): readonly Point[] { + const only = points.length === 1 ? points[0] : undefined; + return only === undefined ? points : [only, { ...only, x: VIEW_WIDTH }]; +} + +/** Selects distinct left, middle, and right labels for the available span. */ +export function chartLabelIndices(periodCount: number): readonly number[] { + if (periodCount <= 0) return []; + return [...new Set([0, Math.floor(periodCount / 2), periodCount - 1])]; +} + function valueFor( totals: DailyTotals | HourlyTotals | undefined, provider: UsageProviderKind, @@ -169,7 +190,40 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re return { max, ticks }; } +/** + * Inclusive day bounds of a brush selection, or null for a plain click. + * Endpoints may arrive in either drag direction. + */ +export function brushSelection( + days: readonly string[], + startIndex: number, + endIndex: number, +): { readonly sinceDay: string; readonly untilDay: string } | null { + if (startIndex === endIndex) return null; + const [first, last] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; + const sinceDay = days[first]; + const untilDay = days[last]; + if (sinceDay === undefined || untilDay === undefined) return null; + return { sinceDay, untilDay }; +} + +/** Period index beneath a pointer, clamped when pointer capture moves outside the plot. */ +export function periodIndexAt( + clientX: number, + plotLeft: number, + plotWidth: number, + periodCount: number, +): number | null { + if (plotWidth <= 0 || periodCount <= 0) return null; + const localX = Math.min(plotWidth, Math.max(0, clientX - plotLeft)); + const fraction = localX / plotWidth; + const index = Math.round(fraction * (periodCount - 1)); + return Math.min(periodCount - 1, Math.max(0, index)); +} + export function UsageProviderChart({ + onZoomToDays, + onResetZoom, providers, days, daily, @@ -189,10 +243,36 @@ export function UsageProviderChart({ [daily, hourly, resolution], ); const [hoverIndex, setHoverIndex] = useState(null); + // Drag-selection endpoints, as period indices. Only daily windows zoom. + const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); + const brushRef = useRef<{ + readonly pointerId: number; + readonly days: readonly string[]; + readonly start: number; + readonly end: number; + } | null>(null); + const zoomable = resolution === "day" && onZoomToDays !== undefined; const plotRef = useRef(null); const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -221,14 +301,11 @@ export function UsageProviderChart({ const built = providers.map((provider) => { const providerIndex = PROVIDER_ORDER.indexOf(provider); - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), - ); + const points = columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })); + const line = curvePath(smoothCurve(spanSinglePeriodPoints(points))); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -288,23 +365,96 @@ export function UsageProviderChart({ return () => observer.disconnect(); }, [hoverIndex, positionTooltip]); + const indexAt = useCallback( + (clientX: number): number | null => { + const plot = plotRef.current; + if (plot === null || periods.length === 0) return null; + const bounds = plot.getBoundingClientRect(); + return periodIndexAt(clientX, bounds.left, bounds.width, periods.length); + }, + [periods.length], + ); + const handleMove = useCallback( (event: React.MouseEvent) => { const plot = plotRef.current; if (plot === null || periods.length === 0) return; const bounds = plot.getBoundingClientRect(); if (bounds.width === 0) return; + if (brushRef.current !== null) return; + const index = indexAt(event.clientX); + if (index === null) return; const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); - const fraction = localX / bounds.width; - const index = Math.round(fraction * (periods.length - 1)); hoverPositionRef.current = { x: localX, y: localY }; positionTooltip(); - setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); + setHoverIndex(index); }, - [periods.length, positionTooltip], + [indexAt, periods.length, positionTooltip], ); + const trackBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + !event.currentTarget.hasPointerCapture(event.pointerId) + ) { + return; + } + const index = indexAt(event.clientX); + if (index === null || index === activeBrush.end) return; + const nextBrush = { ...activeBrush, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + }, + [indexAt], + ); + + const beginBrush = useCallback( + (event: React.PointerEvent) => { + if (!zoomable || event.button !== 0 || !event.isPrimary || brushRef.current !== null) return; + const index = indexAt(event.clientX); + if (index === null) return; + event.currentTarget.setPointerCapture(event.pointerId); + hoverPositionRef.current = null; + setHoverIndex(null); + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + }, + [days, indexAt, zoomable], + ); + + const finishBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + onZoomToDays === undefined + ) { + return; + } + const end = indexAt(event.clientX) ?? activeBrush.end; + const selection = brushSelection(days, activeBrush.start, end); + brushRef.current = null; + setBrush(null); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (selection !== null) onZoomToDays(selection.sinceDay, selection.untilDay); + }, + [days, indexAt, onZoomToDays], + ); + + const cancelBrush = useCallback((event: React.PointerEvent) => { + if (brushRef.current?.pointerId !== event.pointerId) return; + brushRef.current = null; + setBrush(null); + }, []); + const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; const formatPeriod = (period: string) => @@ -332,8 +482,17 @@ export function UsageProviderChart({
{ hoverPositionRef.current = null; setHoverIndex(null); @@ -383,7 +542,22 @@ export function UsageProviderChart({ /> ))} - {hoverIndex === null ? null : ( + {brush === null || brush.start === brush.end ? null : ( + + )} + + {hoverIndex === null || periods.length === 1 ? null : (
- {periods[0] === undefined ? "" : formatPeriod(periods[0])} - - {periods[Math.floor(periods.length / 2)] === undefined - ? "" - : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")} - - - {periods[periods.length - 1] === undefined - ? "" - : formatPeriod(periods[periods.length - 1] ?? "")} - + {chartLabelIndices(periods.length).map((index) => ( + {formatPeriod(periods[index] ?? "")} + ))}
); diff --git a/docs/user/usage.md b/docs/user/usage.md index fba493156dc2..f69d198b5bcd 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -6,6 +6,10 @@ environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. +On web and desktop, the date fields beside the presets accept a custom range of up to 90 days. +Drag across a daily chart to zoom to that range, and double-click the chart to return to the range +you had before zooming. + Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index fb231fbacb20..1ee47bbd838b 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + compareUsageDays, enumerateHourStarts, formatDateTimeShort, formatHourShort, formatRelativeHourShort, + makeCustomWindow, makeWindow, } from "./usageFormat.ts"; @@ -66,8 +68,48 @@ describe("hourly usage formatting", () => { expect(makeWindow(1, now, "hour").timeZone).toBe("UTC"); expect(makeWindow(30, now).timeZone).toBe("UTC"); + expect(makeCustomWindow("2026-08-01", "2026-08-11").timeZone).toBe("UTC"); } finally { resolvedOptions.mockRestore(); } }); }); + +describe("compareUsageDays", () => { + it("orders valid calendar days", () => { + expect(compareUsageDays("2026-08-03", "2026-08-11")).toBe(-1); + expect(compareUsageDays("2026-08-11", "2026-08-03")).toBe(1); + expect(compareUsageDays("2026-08-03", "2026-08-03")).toBe(0); + }); + + it("rejects impossible and malformed days", () => { + expect(compareUsageDays("2026-02-29", "2026-03-01")).toBeNull(); + expect(compareUsageDays("10000-01-01", "9999-12-31")).toBeNull(); + expect(compareUsageDays("", "2026-03-01")).toBeNull(); + }); +}); + +describe("makeCustomWindow", () => { + it("builds a daily window over the inclusive range", () => { + expect(makeCustomWindow("2026-08-03", "2026-08-11")).toMatchObject({ + sinceDay: "2026-08-03", + untilDay: "2026-08-11", + resolution: "day", + }); + }); + + it("swaps out-of-order bounds from a right-to-left drag", () => { + expect(makeCustomWindow("2026-08-11", "2026-08-03")).toMatchObject({ + sinceDay: "2026-08-03", + untilDay: "2026-08-11", + }); + }); + + it("caps ranges at 90 days", () => { + expect(makeCustomWindow("2026-01-01", "2026-12-31").untilDay).toBe("2026-03-31"); + }); + + it("rejects invalid bounds", () => { + expect(() => makeCustomWindow("2026-02-30", "2026-03-01")).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd751829dd87..0063f65b47d2 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -14,6 +14,8 @@ const CURRENCY = new Intl.NumberFormat("en-US", { }); const INTEGER = new Intl.NumberFormat("en-US"); +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_CUSTOM_WINDOW_DAYS = 90; export function formatUsd(value: number): string { return CURRENCY.format(value); @@ -170,15 +172,8 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone); } -/** - * The window the page requests, expressed in the viewer's own time zone so days - * line up with what they actually experienced. - */ -export function makeWindow( - days: number, - now = new Date(), - resolution: UsageResolution = "day", -): UsageSummaryInput { +/** The viewer's zone and a `YYYY-MM-DD` formatter for it; unknown zones fall back to UTC. */ +function viewerDayFormat(): { timeZone: string; format: Intl.DateTimeFormat } { let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; let format: Intl.DateTimeFormat; try { @@ -198,6 +193,53 @@ export function makeWindow( day: "2-digit", }); } + return { timeZone, format }; +} + +/** Strict `YYYY-MM-DD` calendar day, rejecting impossible dates such as `2026-02-30`. */ +function isUsageDay(value: string): boolean { + return ( + /^\d{4}-\d{2}-\d{2}$/.test(value) && + new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value + ); +} + +/** Orders two `YYYY-MM-DD` days, or returns null when either is not a real calendar day. */ +export function compareUsageDays(left: string, right: string): -1 | 0 | 1 | null { + if (!isUsageDay(left) || !isUsageDay(right)) return null; + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * A daily window over an inclusive day range from the date inputs or a chart + * drag. Out-of-order bounds are swapped, and spans are capped at the largest + * preset (90 days) so day enumeration stays bounded. + */ +export function makeCustomWindow(sinceDay: string, untilDay: string): UsageSummaryInput { + const comparison = compareUsageDays(sinceDay, untilDay); + if (comparison === null) throw new RangeError("Usage window bounds must be YYYY-MM-DD dates"); + const [first, last] = comparison <= 0 ? [sinceDay, untilDay] : [untilDay, sinceDay]; + const maxLast = new Date(Date.parse(`${first}T00:00:00Z`) + (MAX_CUSTOM_WINDOW_DAYS - 1) * DAY_MS) + .toISOString() + .slice(0, 10); + return { + sinceDay: UsageDay.make(first), + untilDay: UsageDay.make(last > maxLast ? maxLast : last), + timeZone: viewerDayFormat().timeZone, + resolution: "day", + }; +} + +/** + * The window the page requests, expressed in the viewer's own time zone so days + * line up with what they actually experienced. + */ +export function makeWindow( + days: number, + now = new Date(), + resolution: UsageResolution = "day", +): UsageSummaryInput { + const { timeZone, format } = viewerDayFormat(); const untilDay = format.format(now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an From 1d28ced44264f666e59b831e664305535c05a26f Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 11 Sep 2026 12:59:33 +1000 Subject: [PATCH 2/2] fix(usage): keep year-9999 custom windows valid Co-Authored-By: Claude Opus 5 (1M context) --- packages/shared/src/usageFormat.test.ts | 1 + packages/shared/src/usageFormat.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 1ee47bbd838b..eaabd4dda1ce 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -107,6 +107,7 @@ describe("makeCustomWindow", () => { it("caps ranges at 90 days", () => { expect(makeCustomWindow("2026-01-01", "2026-12-31").untilDay).toBe("2026-03-31"); + expect(makeCustomWindow("9999-12-31", "9999-12-31").untilDay).toBe("9999-12-31"); }); it("rejects invalid bounds", () => { diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 0063f65b47d2..a546ba133cc1 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -219,12 +219,11 @@ export function makeCustomWindow(sinceDay: string, untilDay: string): UsageSumma const comparison = compareUsageDays(sinceDay, untilDay); if (comparison === null) throw new RangeError("Usage window bounds must be YYYY-MM-DD dates"); const [first, last] = comparison <= 0 ? [sinceDay, untilDay] : [untilDay, sinceDay]; - const maxLast = new Date(Date.parse(`${first}T00:00:00Z`) + (MAX_CUSTOM_WINDOW_DAYS - 1) * DAY_MS) - .toISOString() - .slice(0, 10); + const maxLastMs = Date.parse(`${first}T00:00:00Z`) + (MAX_CUSTOM_WINDOW_DAYS - 1) * DAY_MS; + const capped = Date.parse(`${last}T00:00:00Z`) > maxLastMs; return { sinceDay: UsageDay.make(first), - untilDay: UsageDay.make(last > maxLast ? maxLast : last), + untilDay: UsageDay.make(capped ? new Date(maxLastMs).toISOString().slice(0, 10) : last), timeZone: viewerDayFormat().timeZone, resolution: "day", };