From eb634469fa3fef684c3ebe6f128b6d6526b3f898 Mon Sep 17 00:00:00 2001
From: realDiligent
Date: Sat, 11 Jul 2026 05:48:12 +0800
Subject: [PATCH] feat(ui): add analytics dashboard time-window selector
Add a persisted 7d/30d/90d toggle on /app/analytics that re-keys the
operator-dashboard fetch and threads days through the backend payload.
Closes #2199
Co-authored-by: Cursor
---
.../src/lib/analytics-window.test.ts | 79 +++++++++++++++++++
.../gittensory-ui/src/lib/analytics-window.ts | 15 ++++
.../src/routes/app.analytics.tsx | 42 +++++++++-
src/api/routes.ts | 5 +-
src/services/operator-dashboard.ts | 35 +++++---
test/unit/operator-dashboard.test.ts | 37 ++++++++-
6 files changed, 197 insertions(+), 16 deletions(-)
create mode 100644 apps/gittensory-ui/src/lib/analytics-window.test.ts
create mode 100644 apps/gittensory-ui/src/lib/analytics-window.ts
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"),