authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-09-06 06:54:18+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 09:24:18+05:30
logadd8c3a55ac8a7d520dc2ea11a8fa05bc3cee361
treeb5a56d6d3cf2ba69cd3888e00b8fe3d92d5272d7
parent3da9399b1ac4e015f8db29c49718a74c57e62e83
signature Signed by PGP key B5690EEEBB952194

fix(web): remember usage page selection (#10189)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

4 files changed, 151 insertions(+), 23 deletions(-)

apps/web/src/components/usage/UsagePage.test.tsx+21-18
......@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
55
66const testState = vi.hoisted(() => ({
77 useUsage: vi.fn(),
8 metric: "cost" as "cost" | "tokens",
8 metric: "cost" as "cost" | "tokens" | "limits",
99 breakdown: "time" as "model" | "time",
1010}));
1111
......@@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => {
1414 return {
1515 ...actual,
1616 useState: vi.fn((initial: unknown) => [
17 typeof initial === "function"
18 ? {
19 days: 1,
20 window: {
21 sinceDay: "2026-08-10",
22 untilDay: "2026-08-11",
23 timeZone: "UTC",
24 resolution: "hour",
25 sinceTime: "2026-08-10T12:37:00.000Z",
26 untilTime: "2026-08-11T12:37:00.000Z",
27 },
28 }
29 : initial === "cost"
30 ? testState.metric
31 : initial === "model"
32 ? testState.breakdown
33 : initial,
17 initial === readUsagePagePreferences
18 ? { metric: testState.metric, windowDays: 30 }
19 : typeof initial === "function"
20 ? {
21 days: 1,
22 window: {
23 sinceDay: "2026-08-10",
24 untilDay: "2026-08-11",
25 timeZone: "UTC",
26 resolution: "hour",
27 sinceTime: "2026-08-10T12:37:00.000Z",
28 untilTime: "2026-08-11T12:37:00.000Z",
29 },
30 }
31 : initial === "cost"
32 ? testState.metric
33 : initial === "model"
34 ? testState.breakdown
35 : initial,
3436 vi.fn(),
3537 ]),
3638 };
......@@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => {
7072});
7173
7274import { UsagePage } from "./UsagePage";
75import { readUsagePagePreferences } from "./usagePagePreferences";
7376
7477const providerTotals = (codex: number, claude: number) =>
7578 new Map([
apps/web/src/components/usage/UsagePage.tsx+28-5
......@@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits";
6262import { UsagePriceOverrides } from "./UsagePriceOverrides";
6363import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
6464import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
65import {
66 readUsagePagePreferences,
67 saveUsagePagePreferences,
68 type UsagePagePreferences,
69} from "./usagePagePreferences";
6570
6671type UsageMetric = UsageChartMetric | "limits";
6772const METRIC_OPTIONS = [
......@@ -81,12 +86,21 @@ const WINDOW_OPTIONS = [
8186 { days: 90, label: "90 days" },
8287] as const;
8388
89function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] {
90 return WINDOW_OPTIONS.some((option) => option.days === value);
91}
92
8493export function UsagePage() {
94 const [preferences, setPreferences] = useState(readUsagePagePreferences);
8595 const [windowSelection, setWindowSelection] = useState(() => ({
86 days: 30,
87 window: makeWindow(30),
96 days: preferences.windowDays,
97 window: makeWindow(
98 preferences.windowDays,
99 undefined,
100 preferences.windowDays === 1 ? "hour" : "day",
101 ),
88102 }));
89 const [metric, setMetric] = useState<UsageMetric>("cost");
103 const metric = preferences.metric;
90104 const showingLimits = metric === "limits";
91105 const [isRefreshing, setIsRefreshing] = useState(false);
92106 const refreshingRef = useRef(false);
......@@ -134,11 +148,20 @@ export function UsagePage() {
134148 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
135149
136150 const selectWindow = (days: number) => {
151 if (!isUsageWindowDays(days)) return;
152 const nextPreferences = { metric, windowDays: days };
153 setPreferences(nextPreferences);
154 saveUsagePagePreferences(nextPreferences);
137155 setWindowSelection({
138156 days,
139157 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
140158 });
141159 };
160 const selectMetric = (nextMetric: UsageMetric) => {
161 const nextPreferences = { metric: nextMetric, windowDays };
162 setPreferences(nextPreferences);
163 saveUsagePagePreferences(nextPreferences);
164 };
142165 const refreshWindow = () => {
143166 if (refreshingRef.current) return;
144167
......@@ -210,7 +233,7 @@ export function UsagePage() {
210233 value={[metric]}
211234 onValueChange={(next) => {
212235 const value = next[0];
213 if (isUsageMetric(value)) setMetric(value);
236 if (isUsageMetric(value)) selectMetric(value);
214237 }}
215238 >
216239 {METRIC_OPTIONS.map((option) => (
......@@ -252,7 +275,7 @@ export function UsagePage() {
252275 <Select
253276 value={metric}
254277 onValueChange={(value) => {
255 if (isUsageMetric(value)) setMetric(value);
278 if (isUsageMetric(value)) selectMetric(value);
256279 }}
257280 >
258281 <SelectTrigger
apps/web/src/components/usage/usagePagePreferences.test.ts created+70
......@@ -0,0 +1,70 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { readUsagePagePreferences, saveUsagePagePreferences } from "./usagePagePreferences";
4
5const key = "t3code:usage-page-preferences:v1";
6let values: Map<string, string>;
7let storage: Pick<Storage, "getItem" | "setItem">;
8
9beforeEach(() => {
10 values = new Map();
11 storage = {
12 getItem: (key) => values.get(key) ?? null,
13 setItem: (key, value) => {
14 values.set(key, value);
15 },
16 };
17 vi.stubGlobal("window", { localStorage: storage });
18 vi.spyOn(console, "error").mockImplementation(() => {});
19});
20
21afterEach(() => {
22 vi.restoreAllMocks();
23 vi.unstubAllGlobals();
24});
25
26describe("Usage page preferences", () => {
27 it("uses defaults when no preference has been saved", () => {
28 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
29 });
30
31 it.each([1, 7, 30, 90] as const)("round-trips every metric with a %i-day range", (windowDays) => {
32 for (const metric of ["cost", "tokens", "limits"] as const) {
33 saveUsagePagePreferences({ metric, windowDays });
34 expect(readUsagePagePreferences()).toEqual({ metric, windowDays });
35 }
36 });
37
38 it.each([
39 "not-json",
40 '{"metric":"unknown","windowDays":7}',
41 '{"metric":"cost","windowDays":365}',
42 ])("replaces invalid preferences on the next save: %s", (value) => {
43 values.set(key, value);
44 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
45 saveUsagePagePreferences({ metric: "tokens", windowDays: 7 });
46 expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 7 });
47 });
48
49 it("contains write failures and can save again after storage recovers", () => {
50 saveUsagePagePreferences({ metric: "cost", windowDays: 30 });
51 const write = vi.spyOn(storage, "setItem").mockImplementation(() => {
52 throw new Error("QuotaExceededError");
53 });
54 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();
55 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
56 write.mockRestore();
57 saveUsagePagePreferences({ metric: "limits", windowDays: 7 });
58 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 7 });
59 });
60
61 it("contains failures when the browser blocks storage access", () => {
62 vi.stubGlobal("window", {
63 get localStorage() {
64 throw new Error("SecurityError");
65 },
66 });
67 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
68 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();
69 });
70});
apps/web/src/components/usage/usagePagePreferences.ts created+32
......@@ -0,0 +1,32 @@
1import * as Schema from "effect/Schema";
2
3import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage";
4
5const STORAGE_KEY = "t3code:usage-page-preferences:v1";
6const UsagePagePreferencesSchema = Schema.Struct({
7 metric: Schema.Literals(["cost", "tokens", "limits"]),
8 windowDays: Schema.Literals([1, 7, 30, 90]),
9});
10export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;
11
12export function readUsagePagePreferences(): UsagePagePreferences {
13 try {
14 return (
15 getLocalStorageItem(STORAGE_KEY, UsagePagePreferencesSchema) ?? {
16 metric: "cost",
17 windowDays: 30,
18 }
19 );
20 } catch (error) {
21 console.error("Could not read Usage page preferences.", error);
22 return { metric: "cost", windowDays: 30 };
23 }
24}
25
26export function saveUsagePagePreferences(preferences: UsagePagePreferences): void {
27 try {
28 setLocalStorageItem(STORAGE_KEY, preferences, UsagePagePreferencesSchema);
29 } catch (error) {
30 console.error("Could not save Usage page preferences.", error);
31 }
32}