diff --git a/apps/gittensory-ui/src/lib/analytics-window.test.ts b/apps/gittensory-ui/src/lib/analytics-window.test.ts
new file mode 100644
index 0000000000..afa600bd7e
--- /dev/null
+++ b/apps/gittensory-ui/src/lib/analytics-window.test.ts
@@ -0,0 +1,79 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ ANALYTICS_WINDOW_STORAGE_KEY,
+ DEFAULT_ANALYTICS_WINDOW_DAYS,
+ operatorDashboardPath,
+ parseAnalyticsWindowDays,
+} from "@/lib/analytics-window";
+import { useApiResource } from "@/lib/api/use-api-resource";
+import { useLocalStorage } from "@/lib/use-local-storage";
+
+const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
+vi.mock("@/lib/api/request", () => ({
+ apiFetch: (...args: unknown[]) => apiFetch(...args),
+}));
+vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" }));
+
+describe("parseAnalyticsWindowDays (#2199)", () => {
+ it("defaults invalid values to 7d", () => {
+ expect(parseAnalyticsWindowDays(undefined)).toBe(7);
+ expect(parseAnalyticsWindowDays("14")).toBe(7);
+ });
+
+ it("accepts the supported 7/30/90 day windows", () => {
+ expect(parseAnalyticsWindowDays(30)).toBe(30);
+ expect(parseAnalyticsWindowDays("90")).toBe(90);
+ });
+});
+
+describe("operatorDashboardPath (#2199)", () => {
+ it("threads the selected window into the fetch path", () => {
+ expect(operatorDashboardPath(7)).toBe("/v1/app/operator-dashboard?days=7");
+ expect(operatorDashboardPath(30)).toBe("/v1/app/operator-dashboard?days=30");
+ });
+});
+
+describe("analytics window persistence (#2199)", () => {
+ it("restores a persisted value from localStorage", async () => {
+ window.localStorage.setItem(ANALYTICS_WINDOW_STORAGE_KEY, JSON.stringify(90));
+ const { result } = renderHook(() =>
+ useLocalStorage(ANALYTICS_WINDOW_STORAGE_KEY, DEFAULT_ANALYTICS_WINDOW_DAYS),
+ );
+ await waitFor(() => expect(result.current[2]).toBe(true));
+ expect(result.current[0]).toBe(90);
+ });
+
+ it("falls back to the default when storage is empty", async () => {
+ window.localStorage.removeItem(ANALYTICS_WINDOW_STORAGE_KEY);
+ const { result } = renderHook(() =>
+ useLocalStorage(ANALYTICS_WINDOW_STORAGE_KEY, DEFAULT_ANALYTICS_WINDOW_DAYS),
+ );
+ await waitFor(() => expect(result.current[2]).toBe(true));
+ expect(result.current[0]).toBe(DEFAULT_ANALYTICS_WINDOW_DAYS);
+ });
+});
+
+describe("useApiResource window re-key (#2199)", () => {
+ it("refetches when the dashboard path changes", async () => {
+ apiFetch.mockResolvedValue({ ok: true, data: { metrics: [] }, status: 200, durationMs: 5 });
+ const { result, rerender } = renderHook(
+ ({ path }: { path: string }) => useApiResource<{ metrics: [] }>(path, "Product analytics"),
+ { initialProps: { path: operatorDashboardPath(7) } },
+ );
+ await waitFor(() => expect(result.current.status).toBe("ready"));
+ expect(apiFetch).toHaveBeenCalledWith(
+ "https://api.test/v1/app/operator-dashboard?days=7",
+ expect.any(Object),
+ );
+
+ rerender({ path: operatorDashboardPath(30) });
+ await waitFor(() =>
+ expect(apiFetch).toHaveBeenCalledWith(
+ "https://api.test/v1/app/operator-dashboard?days=30",
+ expect.any(Object),
+ ),
+ );
+ });
+});
diff --git a/apps/gittensory-ui/src/lib/analytics-window.ts b/apps/gittensory-ui/src/lib/analytics-window.ts
new file mode 100644
index 0000000000..9b4a275fd4
--- /dev/null
+++ b/apps/gittensory-ui/src/lib/analytics-window.ts
@@ -0,0 +1,15 @@
+export const ANALYTICS_WINDOW_OPTIONS = [7, 30, 90] as const;
+export type AnalyticsWindowDays = (typeof ANALYTICS_WINDOW_OPTIONS)[number];
+export const DEFAULT_ANALYTICS_WINDOW_DAYS: AnalyticsWindowDays = 7;
+export const ANALYTICS_WINDOW_STORAGE_KEY = "gittensory.analytics.windowDays";
+
+export function parseAnalyticsWindowDays(value: unknown): AnalyticsWindowDays {
+ const numeric = Number(value);
+ return ANALYTICS_WINDOW_OPTIONS.includes(numeric as AnalyticsWindowDays)
+ ? (numeric as AnalyticsWindowDays)
+ : DEFAULT_ANALYTICS_WINDOW_DAYS;
+}
+
+export function operatorDashboardPath(windowDays: AnalyticsWindowDays): string {
+ return `/v1/app/operator-dashboard?days=${windowDays}`;
+}
diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx
index 05bf9cdfea..24482cd717 100644
--- a/apps/gittensory-ui/src/routes/app.analytics.tsx
+++ b/apps/gittensory-ui/src/routes/app.analytics.tsx
@@ -4,6 +4,7 @@ import { Download } from "lucide-react";
import { BoundaryBadge, Stat, StatusPill } from "@/components/site/control-primitives";
import { RefreshMeta } from "@/components/site/refresh-meta";
import { StateActionButton, StateBoundary } from "@/components/site/state-views";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { TrendChart } from "@/components/site/trend-chart";
import {
AdoptionRetentionPanel,
@@ -26,8 +27,17 @@ import {
FindingsBreakdownCard,
type FindingsBreakdown,
} from "@/components/site/app-panels/findings-breakdown-card";
+import {
+ ANALYTICS_WINDOW_OPTIONS,
+ ANALYTICS_WINDOW_STORAGE_KEY,
+ DEFAULT_ANALYTICS_WINDOW_DAYS,
+ operatorDashboardPath,
+ parseAnalyticsWindowDays,
+ type AnalyticsWindowDays,
+} from "@/lib/analytics-window";
import { useApiResource } from "@/lib/api/use-api-resource";
import { exportOperatorDashboardCsv } from "@/lib/csv-export";
+import { useLocalStorage } from "@/lib/use-local-storage";
export const Route = createFileRoute("/app/analytics")({
component: ProductAnalytics,
@@ -124,8 +134,13 @@ type OperatorDashboard = {
};
function ProductAnalytics() {
+ const [windowDays, setWindowDays, windowHydrated] = useLocalStorage