From 33a02312d998b209a3facc4c8c85a4f7906ecda2 Mon Sep 17 00:00:00 2001
From: tryeverything24 <114252040+tryeverything24@users.noreply.github.com>
Date: Sun, 26 Jul 2026 04:09:28 +0300
Subject: [PATCH] fix(ui): migrate the pre-rebrand analytics window preference
via the legacyKey fallback
app.analytics.tsx called useLocalStorage with no third legacyKey
argument, unlike its rebrand siblings app.runs.tsx and app.workbench.tsx.
The storage key was renamed from gittensory.analytics.windowDays to
loopover.analytics.windowDays in the same rebrand commit (75450f1d5)
that added the legacyKey migration elsewhere, so a returning user who
had picked a 30d/90d window silently reverted to the 7-day default.
Pass gittensory.analytics.windowDays as the legacy key: the hook reads
it once when the new key is absent, adopts the value, and backfills the
new key, exactly like the runs/workbench view preferences. Regenerates
scripts/branding-drift-baseline.json (branding-drift:update) for the
one intentional legacy-key reference this adds, so branding-drift:check
stays green.
Closes #8699
---
.../src/routes/app.analytics.test.tsx | 61 ++++++++++++++++++-
apps/loopover-ui/src/routes/app.analytics.tsx | 3 +
scripts/branding-drift-baseline.json | 1 +
3 files changed, 63 insertions(+), 2 deletions(-)
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(
diff --git a/scripts/branding-drift-baseline.json b/scripts/branding-drift-baseline.json
index 1b116393fc..cdc06f68d7 100644
--- a/scripts/branding-drift-baseline.json
+++ b/scripts/branding-drift-baseline.json
@@ -1,5 +1,6 @@
{
"apps/loopover-ui/src/components/site/api/try-it.tsx": 1,
+ "apps/loopover-ui/src/routes/app.analytics.tsx": 1,
"apps/loopover-ui/src/routes/app.index.tsx": 1,
"apps/loopover-ui/src/routes/app.runs.tsx": 1,
"apps/loopover-ui/src/routes/app.workbench.tsx": 1,