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() {