Unified · Split
| @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; · 4 unmodified lines | ||
| 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) => { · 2 unmodified lines | ||
| 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) => { · 33 unmodified lines | ||
| 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([ |
| @@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits"; · 61 unmodified lines | ||
| 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 = [ · 13 unmodified lines | ||
| 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() { · 41 unmodified lines | ||
| 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() { · 65 unmodified lines | ||
| 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() { · 35 unmodified lines | ||
| 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 |
| @@ -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 | +}); | |
| @@ -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 | +} | |