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(