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";...@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
55
6const testState = vi.hoisted(() => ({6const testState = vi.hoisted(() => ({
7 useUsage: vi.fn(),7 useUsage: vi.fn(),
8 metric: "cost" as "cost" | "tokens",8 metric: "cost" as "cost" | "tokens" | "limits",
9 breakdown: "time" as "model" | "time",9 breakdown: "time" as "model" | "time",
10}));10}));
1111
...@@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => {...@@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => {
14 return {14 return {
15 ...actual,15 ...actual,
16 useState: vi.fn((initial: unknown) => [16 useState: vi.fn((initial: unknown) => [
17 typeof initial === "function"17 initial === readUsagePagePreferences
18 ? {18 ? { metric: testState.metric, windowDays: 30 }
19 days: 1,19 : typeof initial === "function"
20 window: {20 ? {
21 sinceDay: "2026-08-10",21 days: 1,
22 untilDay: "2026-08-11",22 window: {
23 timeZone: "UTC",23 sinceDay: "2026-08-10",
24 resolution: "hour",24 untilDay: "2026-08-11",
25 sinceTime: "2026-08-10T12:37:00.000Z",25 timeZone: "UTC",
26 untilTime: "2026-08-11T12:37:00.000Z",26 resolution: "hour",
27 },27 sinceTime: "2026-08-10T12:37:00.000Z",
28 }28 untilTime: "2026-08-11T12:37:00.000Z",
29 : initial === "cost"29 },
30 ? testState.metric30 }
31 : initial === "model"31 : initial === "cost"
32 ? testState.breakdown32 ? testState.metric
33 : initial,33 : initial === "model"
34 ? testState.breakdown
35 : initial,
34 vi.fn(),36 vi.fn(),
35 ]),37 ]),
36 };38 };
...@@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => {...@@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => {
70});72});
7173
72import { UsagePage } from "./UsagePage";74import { UsagePage } from "./UsagePage";
75import { readUsagePagePreferences } from "./usagePagePreferences";
7376
74const providerTotals = (codex: number, claude: number) =>77const providerTotals = (codex: number, claude: number) =>
75 new Map([78 new Map([
apps/web/src/components/usage/UsagePage.tsx+28-5
...@@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits";...@@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits";
62import { UsagePriceOverrides } from "./UsagePriceOverrides";62import { UsagePriceOverrides } from "./UsagePriceOverrides";
63import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";63import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
64import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";64import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
65import {
66 readUsagePagePreferences,
67 saveUsagePagePreferences,
68 type UsagePagePreferences,
69} from "./usagePagePreferences";
6570
66type UsageMetric = UsageChartMetric | "limits";71type UsageMetric = UsageChartMetric | "limits";
67const METRIC_OPTIONS = [72const METRIC_OPTIONS = [
...@@ -81,12 +86,21 @@ const WINDOW_OPTIONS = [...@@ -81,12 +86,21 @@ const WINDOW_OPTIONS = [
81 { days: 90, label: "90 days" },86 { days: 90, label: "90 days" },
82] as const;87] as const;
8388
89function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] {
90 return WINDOW_OPTIONS.some((option) => option.days === value);
91}
92
84export function UsagePage() {93export function UsagePage() {
94 const [preferences, setPreferences] = useState(readUsagePagePreferences);
85 const [windowSelection, setWindowSelection] = useState(() => ({95 const [windowSelection, setWindowSelection] = useState(() => ({
86 days: 30,96 days: preferences.windowDays,
87 window: makeWindow(30),97 window: makeWindow(
98 preferences.windowDays,
99 undefined,
100 preferences.windowDays === 1 ? "hour" : "day",
101 ),
88 }));102 }));
89 const [metric, setMetric] = useState<UsageMetric>("cost");103 const metric = preferences.metric;
90 const showingLimits = metric === "limits";104 const showingLimits = metric === "limits";
91 const [isRefreshing, setIsRefreshing] = useState(false);105 const [isRefreshing, setIsRefreshing] = useState(false);
92 const refreshingRef = useRef(false);106 const refreshingRef = useRef(false);
...@@ -134,11 +148,20 @@ export function UsagePage() {...@@ -134,11 +148,20 @@ export function UsagePage() {
134 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;148 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
135149
136 const selectWindow = (days: number) => {150 const selectWindow = (days: number) => {
151 if (!isUsageWindowDays(days)) return;
152 const nextPreferences = { metric, windowDays: days };
153 setPreferences(nextPreferences);
154 saveUsagePagePreferences(nextPreferences);
137 setWindowSelection({155 setWindowSelection({
138 days,156 days,
139 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),157 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
140 });158 });
141 };159 };
160 const selectMetric = (nextMetric: UsageMetric) => {
161 const nextPreferences = { metric: nextMetric, windowDays };
162 setPreferences(nextPreferences);
163 saveUsagePagePreferences(nextPreferences);
164 };
142 const refreshWindow = () => {165 const refreshWindow = () => {
143 if (refreshingRef.current) return;166 if (refreshingRef.current) return;
144167
...@@ -210,7 +233,7 @@ export function UsagePage() {...@@ -210,7 +233,7 @@ export function UsagePage() {
210 value={[metric]}233 value={[metric]}
211 onValueChange={(next) => {234 onValueChange={(next) => {
212 const value = next[0];235 const value = next[0];
213 if (isUsageMetric(value)) setMetric(value);236 if (isUsageMetric(value)) selectMetric(value);
214 }}237 }}
215 >238 >
216 {METRIC_OPTIONS.map((option) => (239 {METRIC_OPTIONS.map((option) => (
...@@ -252,7 +275,7 @@ export function UsagePage() {...@@ -252,7 +275,7 @@ export function UsagePage() {
252 <Select275 <Select
253 value={metric}276 value={metric}
254 onValueChange={(value) => {277 onValueChange={(value) => {
255 if (isUsageMetric(value)) setMetric(value);278 if (isUsageMetric(value)) selectMetric(value);
256 }}279 }}
257 >280 >
258 <SelectTrigger281 <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}