| author | |
| committer | |
| log | add8c3a55ac8a7d520dc2ea11a8fa05bc3cee361 |
| tree | b5a56d6d3cf2ba69cd3888e00b8fe3d92d5272d7 |
| parent | 3da9399b1ac4e015f8db29c49718a74c57e62e83 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 5 | |
| 6 | 6 | const testState = vi.hoisted(() => ({ |
| 7 | 7 | useUsage: vi.fn(), |
| 8 | metric: "cost" as "cost" | "tokens", | |
| 8 | metric: "cost" as "cost" | "tokens" | "limits", | |
| 9 | 9 | breakdown: "time" as "model" | "time", |
| 10 | 10 | })); |
| 11 | 11 | |
| ... | ... | @@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => { |
| 14 | 14 | return { |
| 15 | 15 | ...actual, |
| 16 | 16 | 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, | |
| 34 | 36 | vi.fn(), |
| 35 | 37 | ]), |
| 36 | 38 | }; |
| ... | ... | @@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => { |
| 70 | 72 | }); |
| 71 | 73 | |
| 72 | 74 | import { UsagePage } from "./UsagePage"; |
| 75 | import { readUsagePagePreferences } from "./usagePagePreferences"; | |
| 73 | 76 | |
| 74 | 77 | const providerTotals = (codex: number, claude: number) => |
| 75 | 78 | new Map([ |
apps/web/src/components/usage/UsagePage.tsx+28-5| ... | ... | @@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits"; |
| 62 | 62 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; |
| 63 | 63 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 64 | 64 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| 65 | import { | |
| 66 | readUsagePagePreferences, | |
| 67 | saveUsagePagePreferences, | |
| 68 | type UsagePagePreferences, | |
| 69 | } from "./usagePagePreferences"; | |
| 65 | 70 | |
| 66 | 71 | type UsageMetric = UsageChartMetric | "limits"; |
| 67 | 72 | const METRIC_OPTIONS = [ |
| ... | ... | @@ -81,12 +86,21 @@ const WINDOW_OPTIONS = [ |
| 81 | 86 | { days: 90, label: "90 days" }, |
| 82 | 87 | ] as const; |
| 83 | 88 | |
| 89 | function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { | |
| 90 | return WINDOW_OPTIONS.some((option) => option.days === value); | |
| 91 | } | |
| 92 | ||
| 84 | 93 | export function UsagePage() { |
| 94 | const [preferences, setPreferences] = useState(readUsagePagePreferences); | |
| 85 | 95 | 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 | ), | |
| 88 | 102 | })); |
| 89 | const [metric, setMetric] = useState<UsageMetric>("cost"); | |
| 103 | const metric = preferences.metric; | |
| 90 | 104 | const showingLimits = metric === "limits"; |
| 91 | 105 | const [isRefreshing, setIsRefreshing] = useState(false); |
| 92 | 106 | const refreshingRef = useRef(false); |
| ... | ... | @@ -134,11 +148,20 @@ export function UsagePage() { |
| 134 | 148 | const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; |
| 135 | 149 | |
| 136 | 150 | const selectWindow = (days: number) => { |
| 151 | if (!isUsageWindowDays(days)) return; | |
| 152 | const nextPreferences = { metric, windowDays: days }; | |
| 153 | setPreferences(nextPreferences); | |
| 154 | saveUsagePagePreferences(nextPreferences); | |
| 137 | 155 | setWindowSelection({ |
| 138 | 156 | days, |
| 139 | 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 | 165 | const refreshWindow = () => { |
| 143 | 166 | if (refreshingRef.current) return; |
| 144 | 167 | |
| ... | ... | @@ -210,7 +233,7 @@ export function UsagePage() { |
| 210 | 233 | value={[metric]} |
| 211 | 234 | onValueChange={(next) => { |
| 212 | 235 | const value = next[0]; |
| 213 | if (isUsageMetric(value)) setMetric(value); | |
| 236 | if (isUsageMetric(value)) selectMetric(value); | |
| 214 | 237 | }} |
| 215 | 238 | > |
| 216 | 239 | {METRIC_OPTIONS.map((option) => ( |
| ... | ... | @@ -252,7 +275,7 @@ export function UsagePage() { |
| 252 | 275 | <Select |
| 253 | 276 | value={metric} |
| 254 | 277 | onValueChange={(value) => { |
| 255 | if (isUsageMetric(value)) setMetric(value); | |
| 278 | if (isUsageMetric(value)) selectMetric(value); | |
| 256 | 279 | }} |
| 257 | 280 | > |
| 258 | 281 | <SelectTrigger |
apps/web/src/components/usage/usagePagePreferences.test.ts created+70| ... | ... | @@ -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 created+32| ... | ... | @@ -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 | } |