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
50 changes: 47 additions & 3 deletions apps/web/src/components/usage/UsagePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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",
Expand All @@ -33,14 +38,17 @@ vi.mock("react", async (importOriginal) => {
: initial === "model"
? testState.breakdown
: initial,
vi.fn(),
typeof initial === "function" && initial !== readUsagePagePreferences
? testState.setWindowSelection
: vi.fn(),
]),
};
});

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",
Expand All @@ -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 <div />;
},
}));
vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null }));
vi.mock("./usageProviders", async (importOriginal) => {
const actual = await importOriginal<typeof import("./usageProviders")>();
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -229,3 +250,26 @@ describe("UsagePage model breakdown", () => {
]);
});
});

it("restores the original custom window after repeated chart zooms", () => {
testState.customWindow = true;
renderToStaticMarkup(<UsagePage />);
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(<UsagePage />);
expect(testState.resetZoom).toBeTypeOf("function");
testState.resetZoom?.();
expect(testState.setWindowSelection).not.toHaveBeenCalled();
});
159 changes: 147 additions & 12 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -95,21 +99,23 @@ 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<typeof windowSelection | null>(null);
const metric = preferences.metric;
const showingLimits = metric === "limits";
const [isRefreshing, setIsRefreshing] = useState(false);
const refreshingRef = useRef(false);
const [breakdown, setBreakdown] = useState<"model" | "time">("model");
const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
useState<ReadonlySet<EnvironmentId> | 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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -243,12 +276,18 @@ export function UsagePage() {
</Toggle>
))}
</ToggleGroup>
<UsageDateRangeInputs
sinceDay={window.sinceDay}
untilDay={window.untilDay}
onChange={selectCustomWindow}
disabled={showingLimits}
/>
{/* The period does not apply to Limits, so it stays in place but
disabled; unmounting it shifted the metric toggle ~300px. */}
<ToggleGroup
aria-label="Usage period"
variant="segmented"
value={[String(windowDays)]}
value={isCustomWindow ? [] : [String(windowDays)]}
disabled={showingLimits}
onValueChange={(next) => {
const value = next[0];
Expand Down Expand Up @@ -298,9 +337,11 @@ export function UsagePage() {
</SelectPopup>
</Select>
<Select
value={String(windowDays)}
value={isCustomWindow ? "custom" : String(windowDays)}
disabled={showingLimits}
onValueChange={(value) => selectWindow(Number(value))}
onValueChange={(value) => {
if (value !== "custom" && value !== null) selectWindow(Number(value));
}}
>
<SelectTrigger
aria-label="Usage period"
Expand All @@ -309,7 +350,9 @@ export function UsagePage() {
className="w-auto min-w-0"
>
<SelectValue>
{WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label}
{isCustomWindow
? "Custom"
: WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
Expand Down Expand Up @@ -343,6 +386,14 @@ export function UsagePage() {

<ScrollArea className="min-h-0 flex-1">
<WorkspacePageContainer width="wide">
{!showingLimits ? (
<UsageDateRangeInputs
className="mb-4 flex-wrap xl:hidden"
sinceDay={window.sinceDay}
untilDay={window.untilDay}
onChange={selectCustomWindow}
/>
) : null}
{selectedEnvironments.length === 0 ? (
<p className="text-sm text-muted-foreground">
{environments.length === 0
Expand Down Expand Up @@ -420,10 +471,17 @@ export function UsagePage() {
</div>

<div className="flex min-w-0 flex-col gap-3">
<h2 className="text-sm font-medium text-foreground">
{isPast24Hours ? "Hourly" : "Daily"}{" "}
{metric === "tokens" ? "processed tokens" : "cost"}
</h2>
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-sm font-medium text-foreground">
{isPast24Hours ? "Hourly" : "Daily"}{" "}
{metric === "tokens" ? "processed tokens" : "cost"}
</h2>
{isPast24Hours ? null : (
<span className="text-[10px] tracking-wide text-muted-foreground uppercase">
drag to zoom 路 double-click resets
</span>
)}
</div>
<UsageProviderChart
providers={activeProviders}
days={days}
Expand All @@ -434,6 +492,12 @@ export function UsagePage() {
referenceTime={window.untilTime}
resolution={isPast24Hours ? "hour" : "day"}
timeZone={window.timeZone}
{...(isPast24Hours
? {}
: {
onZoomToDays: zoomToDays,
onResetZoom: resetZoom,
})}
/>
</div>
</section>
Expand Down Expand Up @@ -606,6 +670,77 @@ export function UsagePage() {
);
}

/**
* Free date-range bounds beside the presets. Native date inputs; committing
* either bound deselects every preset. Compact layouts render the same control
* above the page content so custom ranges remain reachable without crowding
* the header.
*/
function UsageDateRangeInputs({
className,
sinceDay,
untilDay,
onChange,
disabled = false,
}: {
readonly className?: string;
readonly sinceDay: string;
readonly untilDay: string;
readonly onChange: (sinceDay: string, untilDay: string) => void;
readonly disabled?: boolean;
}) {
// The shared buffered-input hook preserves a focused draft across upstream
// range changes and commits on both blur and Enter. Keep the hooks separate
// so each bound can validate against the last committed opposite bound.
const sinceInput = useCommitOnBlur(sinceDay, (next) => {
const comparison = compareUsageDays(next, untilDay);
if (comparison !== null && comparison <= 0) onChange(next, untilDay);
});
const untilInput = useCommitOnBlur(untilDay, (next) => {
const comparison = compareUsageDays(sinceDay, next);
if (comparison !== null && comparison <= 0) onChange(sinceDay, next);
});
const comparison = compareUsageDays(sinceInput.value, untilInput.value);
const invalid = comparison === null || comparison > 0;
const inputClassName =
"w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50";

return (
<div
className={cn(
"flex w-fit items-center gap-0.5 rounded-lg bg-input/40 p-0.5 text-xs text-muted-foreground",
className,
)}
>
<Input
nativeInput
unstyled
type="date"
size="compact"
aria-label="From day"
className={cn(inputClassName, "[color-scheme:inherit]")}
max={untilInput.value}
disabled={disabled}
aria-invalid={invalid || undefined}
{...sinceInput}
/>
<span className="px-0.5">to</span>
<Input
nativeInput
unstyled
type="date"
size="compact"
aria-label="To day"
className={cn(inputClassName, "[color-scheme:inherit]")}
min={sinceInput.value}
disabled={disabled}
aria-invalid={invalid || undefined}
{...untilInput}
/>
</div>
);
}

/** Brand mark for the harness a row belongs to. */
function ProviderMark({
provider,
Expand Down
Loading
Loading