diff --git a/apps/loopover-ui/src/routes/app.analytics.test.tsx b/apps/loopover-ui/src/routes/app.analytics.test.tsx index fa84322ecf..7b85c70958 100644 --- a/apps/loopover-ui/src/routes/app.analytics.test.tsx +++ b/apps/loopover-ui/src/routes/app.analytics.test.tsx @@ -1,5 +1,5 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; // #6817: app.analytics.tsx's StateBoundary had no loadingSkeleton, falling through to the generic spinner // -- the same gap #6816 fixed for app.operator.tsx. @@ -42,3 +42,60 @@ describe("ProductAnalytics loading skeleton (#6817)", () => { expect(container.querySelectorAll(".animate-pulse").length).toBe(0); }); }); + +// #8699: the analytics window preference was renamed from "gittensory.analytics.windowDays" to +// "loopover.analytics.windowDays" in the rebrand (75450f1d5) without the one-time legacyKey fallback +// app.runs.tsx / app.workbench.tsx got, silently resetting returning users to the 7-day default. +describe("ProductAnalytics window preference rebrand migration (#8699)", () => { + afterEach(() => { + window.localStorage.clear(); + }); + + it("migrates a pre-rebrand gittensory window preference forward instead of resetting to 7d", async () => { + window.localStorage.setItem("gittensory.analytics.windowDays", "30"); + useApiResource.mockReturnValue({ + status: "ready", + data: { metrics: [{ label: "Active repos", value: "12", delta: "+2" }], noiseReduction: [] }, + error: null, + loadedAt: "2026-07-17T00:00:00.000Z", + reload: () => {}, + }); + + render(); + + // The toggle group renders once the preference has hydrated -- with the legacy value carried + // over, 30d is the selected window (not the 7-day default the user never chose). + const thirtyDay = await screen.findByRole("radio", { name: "30 day window" }); + await waitFor(() => expect(thirtyDay.getAttribute("data-state")).toBe("on")); + expect(screen.getByRole("radio", { name: "7 day window" }).getAttribute("data-state")).toBe( + "off", + ); + // The dashboard is fetched for the migrated window, not the default. + await waitFor(() => + expect(useApiResource).toHaveBeenLastCalledWith( + "/v1/app/operator-dashboard?days=30", + "Product analytics", + ), + ); + // Backfilled: the new key now holds the value directly (the hook migrates it forward on first read). + expect(window.localStorage.getItem("loopover.analytics.windowDays")).toBe("30"); + }); + + it("still uses the 7-day default when neither the new nor the legacy key is present", async () => { + useApiResource.mockReturnValue({ + status: "ready", + data: { metrics: [{ label: "Active repos", value: "12", delta: "+2" }], noiseReduction: [] }, + error: null, + loadedAt: "2026-07-17T00:00:00.000Z", + reload: () => {}, + }); + + render(); + + const sevenDay = await screen.findByRole("radio", { name: "7 day window" }); + await waitFor(() => expect(sevenDay.getAttribute("data-state")).toBe("on")); + // Nothing to migrate: no value is invented under either key. + expect(window.localStorage.getItem("loopover.analytics.windowDays")).toBeNull(); + expect(window.localStorage.getItem("gittensory.analytics.windowDays")).toBeNull(); + }); +}); diff --git a/apps/loopover-ui/src/routes/app.analytics.tsx b/apps/loopover-ui/src/routes/app.analytics.tsx index f852047a5b..c8513a2f6c 100644 --- a/apps/loopover-ui/src/routes/app.analytics.tsx +++ b/apps/loopover-ui/src/routes/app.analytics.tsx @@ -172,6 +172,9 @@ export function ProductAnalytics() { const [windowDays, setWindowDays, windowHydrated] = useLocalStorage( ANALYTICS_WINDOW_STORAGE_KEY, DEFAULT_ANALYTICS_WINDOW_DAYS, + // Pre-rebrand key (#8699): read once as a fallback and migrate forward, mirroring + // app.runs.tsx / app.workbench.tsx, so a returning user's window choice survives the rename. + "gittensory.analytics.windowDays", ); const selectedWindow = parseAnalyticsWindowDays(windowDays); const dashboard = useApiResource(