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( + ANALYTICS_WINDOW_STORAGE_KEY, + DEFAULT_ANALYTICS_WINDOW_DAYS, + ); + const selectedWindow = parseAnalyticsWindowDays(windowDays); const dashboard = useApiResource( - "/v1/app/operator-dashboard", + operatorDashboardPath(selectedWindow), "Product analytics", ); const data = dashboard.status === "ready" ? dashboard.data : null; @@ -161,7 +176,30 @@ function ProductAnalytics() { usage rollups — not security audit logs or private source data.

-
+
+ {windowHydrated ? ( + { + if (!value) return; + setWindowDays(parseAnalyticsWindowDays(Number(value))); + }} + aria-label="Analytics time window" + > + {ANALYTICS_WINDOW_OPTIONS.map((days) => ( + + {days}d + + ))} + + ) : null} { const forbidden = await requireAppRole(c, ["operator"]); if (forbidden) return forbidden; - return c.json(await buildOperatorDashboardPayload(c.env)); + const days = clampOperatorDashboardWindowDays(Number(c.req.query("days"))); + return c.json(await buildOperatorDashboardPayload(c.env, { windowDays: days })); }); // Dead-letter-queue table view (#2214), read-only: the self-host queue backend's admin surface is mirrored diff --git a/src/services/operator-dashboard.ts b/src/services/operator-dashboard.ts index f689429c81..59156c1c49 100644 --- a/src/services/operator-dashboard.ts +++ b/src/services/operator-dashboard.ts @@ -72,9 +72,16 @@ export type OperatorDashboardPayload = { }; const USAGE_WINDOW_DAYS = 7; +// Gate-precision and cycle-time cards (#2191/#2194) keep a fixed 90d lookback for statistical stability. +const GATE_ANALYTICS_WINDOW_DAYS = 90; -export async function buildOperatorDashboardPayload(env: Env): Promise { - const usageSince = new Date(Date.now() - USAGE_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString(); +export async function buildOperatorDashboardPayload( + env: Env, + options: { windowDays?: number } = {}, +): Promise { + const windowDays = clampOperatorDashboardWindowDays(options.windowDays); + const usageSince = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000).toISOString(); + const mcpSince = new Date(Date.now() - USAGE_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString(); const [ repositories, installations, @@ -108,20 +115,20 @@ export async function buildOperatorDashboardPayload(env: Env): Promise { ); }); + it("clamps unsupported window values to the default 7d lookback (#2199)", () => { + expect(clampOperatorDashboardWindowDays(30)).toBe(30); + expect(clampOperatorDashboardWindowDays(14)).toBe(7); + }); + + it("threads a custom windowDays through command usefulness metadata (#2199)", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "operator-dashboard-test-salt" }); + const payload = await buildOperatorDashboardPayload(env, { windowDays: 90 }); + expect(payload.commandUsefulness.windowDays).toBe(90); + expect(payload.metrics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "Product events", delta: "last 90 days" }), + ]), + ); + }); + + it("keeps gate-precision and cycle-time cards on a fixed 90d lookback when windowDays is 7 (#2199)", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "operator-dashboard-test-salt" }); + const payload = await buildOperatorDashboardPayload(env, { windowDays: 7 }); + expect(payload.commandUsefulness.windowDays).toBe(7); + expect(payload.gateEval).toEqual({ rows: [], hasSignal: false }); + expect(payload.cycleTime).toEqual({ + p50Ms: null, + p90Ms: null, + p99Ms: null, + distribution: [], + sampleSize: 0, + }); + }); + it("picks the newest rollup day for adoption insights", () => { const rollups: ProductUsageDailyRollupRecord[] = [ rollup("2026-05-28"),