Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<ReadonlySet<EnvironmentId> | null>(null);
useState<ReadonlySet<EnvironmentId> | null>(
toSelectedEnvironmentIds(preferences.selectedEnvironmentIds),
);
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
Expand Down Expand Up @@ -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({
Expand All @@ -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<EnvironmentId> | null) => {
setSelectedEnvironmentIds(ids);
const nextPreferences = {
metric,
windowDays,
selectedEnvironmentIds: fromSelectedEnvironmentIds(ids),
};
setPreferences(nextPreferences);
saveUsagePagePreferences(nextPreferences);
};
Expand Down Expand Up @@ -213,7 +237,7 @@ export function UsagePage() {
environments={environments}
selectedEnvironments={selectedEnvironments}
selectedEnvironmentIds={selectedEnvironmentIds}
onSelectionChange={setSelectedEnvironmentIds}
onSelectionChange={selectEnvironments}
showUsageStatus={!showingLimits}
isPartial={isPartial}
duplicateSources={merged.duplicateSources}
Expand Down
108 changes: 94 additions & 14 deletions apps/web/src/components/usage/usagePagePreferences.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
Expand All @@ -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", () => {
Expand All @@ -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([]);
});
});
42 changes: 39 additions & 3 deletions apps/web/src/components/usage/usagePagePreferences.ts
Original file line number Diff line number Diff line change
@@ -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<EnvironmentId> | null {
if (stored === null || stored === undefined) return null;
const ids = new Set<string>();
for (const id of stored) {
const trimmed = id.trim();
if (trimmed !== "") ids.add(trimmed);
}
return new Set(ids as Set<EnvironmentId>);
}

/** Selection back to a storable shape, deduplicated. */
export function fromSelectedEnvironmentIds(
selected: ReadonlySet<EnvironmentId> | 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,
};
}
}

Expand Down
Loading