History

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

Exotic committed 2 months ago

Commit add8c3a1 parent 3da93994 files · +151 −23Browse files

Unified · Split

4 files
  1. apps/web/src/components/usage/UsagePage.test.tsx +21 −18
  2. apps/web/src/components/usage/UsagePage.tsx +28 −5
  3. apps/web/src/components/usage/usagePagePreferences.test.ts +70 −0
  4. apps/web/src/components/usage/usagePagePreferences.ts +32 −0
apps/web/src/components/usage/UsagePage.test.tsx+21−18
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; · 4 unmodified lines
55
66 const 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) => { · 2 unmodified lines
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) => { · 33 unmodified lines
7072 });
7173
7274 import { UsagePage } from "./UsagePage";
75+import { readUsagePagePreferences } from "./usagePagePreferences";
7376
7477 const 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"; · 61 unmodified lines
6262 import { UsagePriceOverrides } from "./UsagePriceOverrides";
6363 import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
6464 import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
65+import {
66+ readUsagePagePreferences,
67+ saveUsagePagePreferences,
68+ type UsagePagePreferences,
69+} from "./usagePagePreferences";
6570
6671 type UsageMetric = UsageChartMetric | "limits";
6772 const METRIC_OPTIONS = [
@@ -81,12 +86,21 @@ const WINDOW_OPTIONS = [ · 13 unmodified lines
8186 { days: 90, label: "90 days" },
8287 ] as const;
8388
89+function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] {
90+ return WINDOW_OPTIONS.some((option) => option.days === value);
91+}
92+
8493 export 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() { · 41 unmodified lines
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() { · 65 unmodified lines
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() { · 35 unmodified lines
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+70−0
@@ -0,0 +1,70 @@
1+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+import { readUsagePagePreferences, saveUsagePagePreferences } from "./usagePagePreferences";
4+
5+const key = "t3code:usage-page-preferences:v1";
6+let values: Map<string, string>;
7+let storage: Pick<Storage, "getItem" | "setItem">;
8+
9+beforeEach(() => {
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+
21+afterEach(() => {
22+ vi.restoreAllMocks();
23+ vi.unstubAllGlobals();
24+});
25+
26+describe("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+32−0
@@ -0,0 +1,32 @@
1+import * as Schema from "effect/Schema";
2+
3+import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage";
4+
5+const STORAGE_KEY = "t3code:usage-page-preferences:v1";
6+const UsagePagePreferencesSchema = Schema.Struct({
7+ metric: Schema.Literals(["cost", "tokens", "limits"]),
8+ windowDays: Schema.Literals([1, 7, 30, 90]),
9+});
10+export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;
11+
12+export 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+
26+export 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+}