| 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,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 5 | 5 | ||
| 6 | const testState = vi.hoisted(() => ({ | 6 | const 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 | })); |
| 11 | 11 | ||
| ... | @@ -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.metric | 30 | } |
| 31 | : initial === "model" | 31 | : initial === "cost" |
| 32 | ? testState.breakdown | 32 | ? 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 | }); |
| 71 | 73 | ||
| 72 | import { UsagePage } from "./UsagePage"; | 74 | import { UsagePage } from "./UsagePage"; |
| 75 | import { readUsagePagePreferences } from "./usagePagePreferences"; | ||
| 73 | 76 | ||
| 74 | const providerTotals = (codex: number, claude: number) => | 77 | const 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"; |
| 62 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; | 62 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; |
| 63 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 63 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 64 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; | 64 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| 65 | import { | ||
| 66 | readUsagePagePreferences, | ||
| 67 | saveUsagePagePreferences, | ||
| 68 | type UsagePagePreferences, | ||
| 69 | } from "./usagePagePreferences"; | ||
| 65 | 70 | ||
| 66 | type UsageMetric = UsageChartMetric | "limits"; | 71 | type UsageMetric = UsageChartMetric | "limits"; |
| 67 | const METRIC_OPTIONS = [ | 72 | const 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; |
| 83 | 88 | ||
| 89 | function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { | ||
| 90 | return WINDOW_OPTIONS.some((option) => option.days === value); | ||
| 91 | } | ||
| 92 | |||
| 84 | export function UsagePage() { | 93 | export 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)}%`; |
| 135 | 149 | ||
| 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; |
| 144 | 167 | ||
| ... | @@ -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 | <Select | 275 | <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 | <SelectTrigger | 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 | } | ||