diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..35c9081c6d46 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -63,8 +63,10 @@ import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; import { + fromSelectedEnvironmentIds, readUsagePagePreferences, saveUsagePagePreferences, + toSelectedEnvironmentIds, type UsagePagePreferences, } from "./usagePagePreferences"; @@ -105,8 +107,12 @@ export function UsagePage() { const [isRefreshing, setIsRefreshing] = useState(false); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + // Restored from the same preferences that remember the metric and period, + // so a deselected environment stays deselected across visits. const [selectedEnvironmentIds, setSelectedEnvironmentIds] = - useState | null>(null); + useState | null>( + toSelectedEnvironmentIds(preferences.selectedEnvironmentIds), + ); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( @@ -149,7 +155,11 @@ export function UsagePage() { const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; - const nextPreferences = { metric, windowDays: days }; + const nextPreferences = { + metric, + windowDays: days, + selectedEnvironmentIds: fromSelectedEnvironmentIds(selectedEnvironmentIds), + }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); setWindowSelection({ @@ -158,7 +168,21 @@ export function UsagePage() { }); }; const selectMetric = (nextMetric: UsageMetric) => { - const nextPreferences = { metric: nextMetric, windowDays }; + const nextPreferences = { + metric: nextMetric, + windowDays, + selectedEnvironmentIds: fromSelectedEnvironmentIds(selectedEnvironmentIds), + }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); + }; + const selectEnvironments = (ids: ReadonlySet | null) => { + setSelectedEnvironmentIds(ids); + const nextPreferences = { + metric, + windowDays, + selectedEnvironmentIds: fromSelectedEnvironmentIds(ids), + }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); }; @@ -213,7 +237,7 @@ export function UsagePage() { environments={environments} selectedEnvironments={selectedEnvironments} selectedEnvironmentIds={selectedEnvironmentIds} - onSelectionChange={setSelectedEnvironmentIds} + onSelectionChange={selectEnvironments} showUsageStatus={!showingLimits} isPartial={isPartial} duplicateSources={merged.duplicateSources} diff --git a/apps/web/src/components/usage/usagePagePreferences.test.ts b/apps/web/src/components/usage/usagePagePreferences.test.ts index dcdde98fa247..4a5fc5901ef1 100644 --- a/apps/web/src/components/usage/usagePagePreferences.test.ts +++ b/apps/web/src/components/usage/usagePagePreferences.test.ts @@ -1,6 +1,12 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { readUsagePagePreferences, saveUsagePagePreferences } from "./usagePagePreferences"; +import { + fromSelectedEnvironmentIds, + readUsagePagePreferences, + saveUsagePagePreferences, + toSelectedEnvironmentIds, +} from "./usagePagePreferences"; const key = "t3code:usage-page-preferences:v1"; let values: Map; @@ -25,37 +31,83 @@ afterEach(() => { describe("Usage page preferences", () => { it("uses defaults when no preference has been saved", () => { - expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); + expect(readUsagePagePreferences()).toEqual({ + metric: "cost", + windowDays: 30, + selectedEnvironmentIds: null, + }); }); it.each([1, 7, 30, 90] as const)("round-trips every metric with a %i-day range", (windowDays) => { for (const metric of ["cost", "tokens", "limits"] as const) { - saveUsagePagePreferences({ metric, windowDays }); - expect(readUsagePagePreferences()).toEqual({ metric, windowDays }); + saveUsagePagePreferences({ metric, windowDays, selectedEnvironmentIds: null }); + expect(readUsagePagePreferences()).toEqual({ + metric, + windowDays, + selectedEnvironmentIds: null, + }); } }); + it("round-trips an explicit environment selection", () => { + saveUsagePagePreferences({ metric: "cost", windowDays: 7, selectedEnvironmentIds: ["env-b"] }); + expect(readUsagePagePreferences()).toEqual({ + metric: "cost", + windowDays: 7, + selectedEnvironmentIds: ["env-b"], + }); + }); + + it("reads preferences saved before the environment filter was persisted", () => { + values.set(key, '{"metric":"tokens","windowDays":7}'); + const preferences = readUsagePagePreferences(); + expect(preferences.metric).toBe("tokens"); + expect(preferences.windowDays).toBe(7); + expect(preferences.selectedEnvironmentIds ?? null).toBeNull(); + expect(toSelectedEnvironmentIds(preferences.selectedEnvironmentIds)).toBeNull(); + }); + it.each([ "not-json", '{"metric":"unknown","windowDays":7}', '{"metric":"cost","windowDays":365}', + '{"metric":"cost","windowDays":7,"selectedEnvironmentIds":"nope"}', + '{"metric":"cost","windowDays":7,"selectedEnvironmentIds":[42]}', ])("replaces invalid preferences on the next save: %s", (value) => { values.set(key, value); - expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); - saveUsagePagePreferences({ metric: "tokens", windowDays: 7 }); - expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 7 }); + expect(readUsagePagePreferences()).toEqual({ + metric: "cost", + windowDays: 30, + selectedEnvironmentIds: null, + }); + saveUsagePagePreferences({ metric: "tokens", windowDays: 7, selectedEnvironmentIds: null }); + expect(readUsagePagePreferences()).toEqual({ + metric: "tokens", + windowDays: 7, + selectedEnvironmentIds: null, + }); }); it("contains write failures and can save again after storage recovers", () => { - saveUsagePagePreferences({ metric: "cost", windowDays: 30 }); + saveUsagePagePreferences({ metric: "cost", windowDays: 30, selectedEnvironmentIds: null }); const write = vi.spyOn(storage, "setItem").mockImplementation(() => { throw new Error("QuotaExceededError"); }); - expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow(); - expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); + expect(() => + saveUsagePagePreferences({ metric: "tokens", windowDays: 7, selectedEnvironmentIds: null }), + ).not.toThrow(); + expect(readUsagePagePreferences()).toEqual({ + metric: "cost", + windowDays: 30, + selectedEnvironmentIds: null, + }); write.mockRestore(); - saveUsagePagePreferences({ metric: "limits", windowDays: 7 }); - expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 7 }); + saveUsagePagePreferences({ metric: "limits", windowDays: 7, selectedEnvironmentIds: null }); + expect(readUsagePagePreferences()).toEqual({ + metric: "limits", + windowDays: 7, + selectedEnvironmentIds: null, + }); }); it("contains failures when the browser blocks storage access", () => { @@ -64,7 +116,35 @@ describe("Usage page preferences", () => { throw new Error("SecurityError"); }, }); - expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); - expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow(); + expect(readUsagePagePreferences()).toEqual({ + metric: "cost", + windowDays: 30, + selectedEnvironmentIds: null, + }); + expect(() => + saveUsagePagePreferences({ metric: "tokens", windowDays: 7, selectedEnvironmentIds: null }), + ).not.toThrow(); + }); +}); + +describe("Usage page environment selection", () => { + it("maps null and missing stored ids to all environments", () => { + expect(toSelectedEnvironmentIds(null)).toBeNull(); + expect(toSelectedEnvironmentIds(undefined)).toBeNull(); + }); + + it("restores an explicit selection and keeps an empty selection empty", () => { + expect(toSelectedEnvironmentIds(["env-a", "env-b"])).toEqual(new Set(["env-a", "env-b"])); + expect(toSelectedEnvironmentIds([])).toEqual(new Set()); + }); + + it("drops blank stored ids", () => { + expect(toSelectedEnvironmentIds(["env-a", " ", ""])).toEqual(new Set(["env-a"])); + }); + + it("stores null for all environments and an array otherwise", () => { + expect(fromSelectedEnvironmentIds(null)).toBeNull(); + expect(fromSelectedEnvironmentIds(new Set([EnvironmentId.make("env-a")]))).toEqual(["env-a"]); + expect(fromSelectedEnvironmentIds(new Set())).toEqual([]); }); }); diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts index ddd6d5341cc0..1015e6dcf1f2 100644 --- a/apps/web/src/components/usage/usagePagePreferences.ts +++ b/apps/web/src/components/usage/usagePagePreferences.ts @@ -1,25 +1,61 @@ import * as Schema from "effect/Schema"; +import type { EnvironmentId } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage"; const STORAGE_KEY = "t3code:usage-page-preferences:v1"; +const DEFAULTS = { + metric: "cost", + windowDays: 30, + selectedEnvironmentIds: null, +} as const; const UsagePagePreferencesSchema = Schema.Struct({ metric: Schema.Literals(["cost", "tokens", "limits"]), windowDays: Schema.Literals([1, 7, 30, 90]), + // Null follows all environments, including ones connected later. An array is + // an explicit subset; an empty array means nothing is selected. Optional so + // preferences saved before this field existed still decode. + selectedEnvironmentIds: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), }); export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; +/** Stored ids back to the selection the usage query expects. */ +export function toSelectedEnvironmentIds( + stored: readonly string[] | null | undefined, +): ReadonlySet | null { + if (stored === null || stored === undefined) return null; + const ids = new Set(); + for (const id of stored) { + const trimmed = id.trim(); + if (trimmed !== "") ids.add(trimmed); + } + return new Set(ids as Set); +} + +/** Selection back to a storable shape, deduplicated. */ +export function fromSelectedEnvironmentIds( + selected: ReadonlySet | null, +): readonly string[] | null { + if (selected === null) return null; + return [...new Set([...selected].map((id) => String(id).trim()).filter((id) => id !== ""))]; +} + export function readUsagePagePreferences(): UsagePagePreferences { try { return ( getLocalStorageItem(STORAGE_KEY, UsagePagePreferencesSchema) ?? { - metric: "cost", - windowDays: 30, + metric: DEFAULTS.metric, + windowDays: DEFAULTS.windowDays, + selectedEnvironmentIds: DEFAULTS.selectedEnvironmentIds, } ); } catch (error) { console.error("Could not read Usage page preferences.", error); - return { metric: "cost", windowDays: 30 }; + return { + metric: DEFAULTS.metric, + windowDays: DEFAULTS.windowDays, + selectedEnvironmentIds: DEFAULTS.selectedEnvironmentIds, + }; } }