From 6e160b8ef2fcdcbd0c02f84a55f437da50fb5a96 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:58:40 -0700 Subject: [PATCH] feat(ui): dual-read browser localStorage keys during the rebrand Renames 6 client-side localStorage keys from gittensory.* to loopover.* (workbench tab, saved run views, onboarding checklist state, notification opt-in, the API playground session token, and the maintainer onboarding-preview dismiss flag), with a one-time migration fallback: read the new key, fall back to the legacy key if absent, and write the value forward so every later read hits the new key directly. Renaming these outright would have silently reset every returning visitor's saved state -- most notably logging out anyone with a saved session token. useLocalStorage (used by 4 of the 6 sites) gains an optional legacyKey parameter implementing this pattern once; the two call sites using raw localStorage directly (the API playground token, and the one dismiss-flag site that already used the hook) get the same treatment inline / via the hook respectively. Also fixes a real user-facing "Gittensory" brand-string left in the onboarding preview card's rendered heading, caught while touching this file for its dismiss-key migration. --- .../src/components/site/api/try-it.test.ts | 24 ++++++++ .../src/components/site/api/try-it.tsx | 18 +++++- .../onboarding-preview-card.test.tsx | 4 +- .../app-panels/onboarding-preview-card.tsx | 16 +++-- .../site/notification-readiness-card.tsx | 6 +- .../src/lib/use-local-storage.test.ts | 58 +++++++++++++++++++ .../src/lib/use-local-storage.ts | 19 +++++- apps/gittensory-ui/src/routes/app.index.tsx | 2 +- apps/gittensory-ui/src/routes/app.runs.tsx | 6 +- .../src/routes/app.workbench.tsx | 6 +- 10 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 apps/gittensory-ui/src/components/site/api/try-it.test.ts create mode 100644 apps/gittensory-ui/src/lib/use-local-storage.test.ts diff --git a/apps/gittensory-ui/src/components/site/api/try-it.test.ts b/apps/gittensory-ui/src/components/site/api/try-it.test.ts new file mode 100644 index 0000000000..37b164cba0 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/api/try-it.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { readStoredSessionToken } from "@/components/site/api/try-it"; + +describe("readStoredSessionToken legacyKey migration (rebrand key rename)", () => { + it("reads the new key directly when present, ignoring any legacy key", () => { + window.localStorage.clear(); + window.localStorage.setItem("loopover.session_token", "new-token"); + window.localStorage.setItem("gittensory.session_token", "legacy-token"); + expect(readStoredSessionToken(window.localStorage)).toBe("new-token"); + }); + + it("falls back to the legacy key when the new key is absent, and migrates the value forward", () => { + window.localStorage.clear(); + window.localStorage.setItem("gittensory.session_token", "legacy-token"); + expect(readStoredSessionToken(window.localStorage)).toBe("legacy-token"); + expect(window.localStorage.getItem("loopover.session_token")).toBe("legacy-token"); + }); + + it("returns an empty string when neither key is present", () => { + window.localStorage.clear(); + expect(readStoredSessionToken(window.localStorage)).toBe(""); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/api/try-it.tsx b/apps/gittensory-ui/src/components/site/api/try-it.tsx index 219629391a..771d6ee6b5 100644 --- a/apps/gittensory-ui/src/components/site/api/try-it.tsx +++ b/apps/gittensory-ui/src/components/site/api/try-it.tsx @@ -16,7 +16,21 @@ import { useApiStatus, } from "@/lib/api/status"; -const STORAGE_KEY = "gittensory.session_token"; +const STORAGE_KEY = "loopover.session_token"; +// One-time rebrand migration fallback -- read once, then written forward to STORAGE_KEY below. +const LEGACY_STORAGE_KEY = "gittensory.session_token"; + +/** Reads the session token, falling back to (and migrating forward from) the pre-rebrand legacy key. */ +export function readStoredSessionToken(storage: Pick): string { + const stored = storage.getItem(STORAGE_KEY); + if (stored !== null) return stored; + const legacy = storage.getItem(LEGACY_STORAGE_KEY); + if (legacy !== null) { + storage.setItem(STORAGE_KEY, legacy); + return legacy; + } + return ""; +} interface Result { status: number; @@ -52,7 +66,7 @@ export function TryIt({ op, server }: { op: OpenApiOperation; server: string }) const runRef = useRef<() => Promise>(() => Promise.resolve()); useEffect(() => { - setToken(localStorage.getItem(STORAGE_KEY) ?? ""); + setToken(readStoredSessionToken(localStorage)); setResult(null); setError(null); setPathParams({}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx index 4516ef2f9c..1570c55956 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx @@ -134,7 +134,7 @@ describe("OnboardingPreviewCard", () => { await waitFor(() => expect(screen.getByText("Would comment and label this PR.")).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "Dismiss onboarding preview" })); - expect(screen.queryByText(/Here's what Gittensory would have flagged/)).toBeNull(); + expect(screen.queryByText(/Here's what LoopOver would have flagged/)).toBeNull(); apiFetch.mockClear(); unmount(); @@ -142,7 +142,7 @@ describe("OnboardingPreviewCard", () => { // assumption ActivationPreview's tests already rely on for its own initial-load effect), so both of // these are safe to assert immediately rather than under waitFor. render(); - expect(screen.queryByText(/Here's what Gittensory would have flagged/)).toBeNull(); + expect(screen.queryByText(/Here's what LoopOver would have flagged/)).toBeNull(); expect(apiFetch).not.toHaveBeenCalled(); }); }); diff --git a/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.tsx index 6c2df935a2..fe3c3e903d 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.tsx @@ -17,7 +17,9 @@ import { useLocalStorage } from "@/lib/use-local-storage"; type ReviewabilityRow = { pr: string; title: string; reason: string }; -const DISMISS_KEY = "gittensory_maintainer_onboarding_preview_dismissed"; +const DISMISS_KEY = "loopover_maintainer_onboarding_preview_dismissed"; +// One-time rebrand migration fallback -- see useLocalStorage's legacyKey param. +const LEGACY_DISMISS_KEY = "gittensory_maintainer_onboarding_preview_dismissed"; /** Builds a settings-preview form from a REAL cached PR (title, and a linked-issue number scraped from * `reason` when present) — everything else (author identity, labels, body) isn't in the reviewability @@ -40,16 +42,18 @@ function reviewabilityRowToForm(row: ReviewabilityRow): PreviewFormState | null /** * First-session onboarding preview card (#2217, part of #701): auto-runs the same settings-preview * simulator SurfacePreview drives manually, against this repo's most recently cached pull request, so a - * maintainer sees "here's what Gittensory would have flagged" without filling out a form. Dismissible via + * maintainer sees "here's what LoopOver would have flagged" without filling out a form. Dismissible via * localStorage, matching this codebase's established first-visit-card idiom (app.index.tsx's * OnboardingChecklist). Renders through PreviewResult — the same decision/checklist/comment-preview UI * SurfacePreview already uses — rather than a new findings UI: the settings-preview response has no * discrete findings array, so "flagged" here means decision.willComment / willLabel / willCheckRun. */ export function OnboardingPreviewCard({ reviewability }: { reviewability: ReviewabilityRow[] }) { - const [state, setState, hydrated] = useLocalStorage<{ dismissed: boolean }>(DISMISS_KEY, { - dismissed: false, - }); + const [state, setState, hydrated] = useLocalStorage<{ dismissed: boolean }>( + DISMISS_KEY, + { dismissed: false }, + LEGACY_DISMISS_KEY, + ); const target = reviewability[0] ?? null; const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(Boolean(target)); @@ -104,7 +108,7 @@ export function OnboardingPreviewCard({ reviewability }: { reviewability: Review

- Here's what Gittensory would have flagged + Here's what LoopOver would have flagged

{target ? ( diff --git a/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx index 3cf2a3943a..09a5318a18 100644 --- a/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx +++ b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx @@ -29,7 +29,11 @@ export function NotificationReadinessCard() { "/v1/app/notification-model", "Notification model", ); - const [optIn, setOptIn] = useLocalStorage("gittensory_notification_opt_in", false); + const [optIn, setOptIn] = useLocalStorage( + "loopover_notification_opt_in", + false, + "gittensory_notification_opt_in", + ); const [busy, setBusy] = useState(false); const permission = typeof Notification === "undefined" ? "unsupported" : Notification.permission; diff --git a/apps/gittensory-ui/src/lib/use-local-storage.test.ts b/apps/gittensory-ui/src/lib/use-local-storage.test.ts new file mode 100644 index 0000000000..0bbd619e78 --- /dev/null +++ b/apps/gittensory-ui/src/lib/use-local-storage.test.ts @@ -0,0 +1,58 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useLocalStorage } from "@/lib/use-local-storage"; + +describe("useLocalStorage legacyKey migration (rebrand key rename)", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("reads the new key directly when it's already present, ignoring any legacy key", async () => { + window.localStorage.setItem("new.key", JSON.stringify("from-new")); + window.localStorage.setItem("legacy.key", JSON.stringify("from-legacy")); + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("from-new"); + }); + + it("falls back to the legacy key when the new key is absent, and migrates the value forward", async () => { + window.localStorage.setItem("legacy.key", JSON.stringify("carried-over")); + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("carried-over"); + // Migrated forward: the new key now holds the value directly, without removing the legacy key. + expect(window.localStorage.getItem("new.key")).toBe(JSON.stringify("carried-over")); + expect(window.localStorage.getItem("legacy.key")).toBe(JSON.stringify("carried-over")); + }); + + it("uses the initial value when neither the new nor the legacy key is present", async () => { + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("initial"); + expect(window.localStorage.getItem("new.key")).toBeNull(); + }); + + it("behaves exactly as before when no legacyKey is given at all", async () => { + window.localStorage.setItem("solo.key", JSON.stringify("value")); + const { result } = renderHook(() => useLocalStorage("solo.key", "initial")); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("value"); + }); + + it("writes through the new key going forward after a migration", async () => { + window.localStorage.setItem("legacy.key", JSON.stringify("old-value")); + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + act(() => result.current[1]("new-value")); + expect(window.localStorage.getItem("new.key")).toBe(JSON.stringify("new-value")); + }); +}); diff --git a/apps/gittensory-ui/src/lib/use-local-storage.ts b/apps/gittensory-ui/src/lib/use-local-storage.ts index 73d5e8cb37..dc0b7ee649 100644 --- a/apps/gittensory-ui/src/lib/use-local-storage.ts +++ b/apps/gittensory-ui/src/lib/use-local-storage.ts @@ -3,20 +3,33 @@ import { useCallback, useEffect, useState } from "react"; /** * Tiny SSR-safe localStorage hook. Reads once on mount; writes are persisted * synchronously and broadcast via a `storage` event for other tabs. + * + * `legacyKey`, when given, is read as a one-time fallback if `key` is absent + * (a rebrand key-rename migration) -- the value found there is written + * forward to `key` immediately so every later read hits the new key + * directly. The legacy key is left in place, unremoved. */ -export function useLocalStorage(key: string, initial: T) { +export function useLocalStorage(key: string, initial: T, legacyKey?: string) { const [value, setValue] = useState(initial); const [hydrated, setHydrated] = useState(false); useEffect(() => { try { const raw = window.localStorage.getItem(key); - if (raw !== null) setValue(JSON.parse(raw) as T); + if (raw !== null) { + setValue(JSON.parse(raw) as T); + } else if (legacyKey) { + const legacyRaw = window.localStorage.getItem(legacyKey); + if (legacyRaw !== null) { + setValue(JSON.parse(legacyRaw) as T); + window.localStorage.setItem(key, legacyRaw); + } + } } catch { /* ignore */ } setHydrated(true); - }, [key]); + }, [key, legacyKey]); const update = useCallback( (next: T | ((prev: T) => T)) => { diff --git a/apps/gittensory-ui/src/routes/app.index.tsx b/apps/gittensory-ui/src/routes/app.index.tsx index 5f70627b4c..ea6518227a 100644 --- a/apps/gittensory-ui/src/routes/app.index.tsx +++ b/apps/gittensory-ui/src/routes/app.index.tsx @@ -434,7 +434,7 @@ function OnboardingChecklist() { const [state, setState, hydrated] = useLocalStorage<{ dismissed: boolean; done: Record; - }>("gittensory.onboarding", { dismissed: false, done: {} }); + }>("loopover.onboarding", { dismissed: false, done: {} }, "gittensory.onboarding"); if (!hydrated || state.dismissed) return null; const completed = ONBOARDING_STEPS.filter((s) => state.done[s.id]).length; return ( diff --git a/apps/gittensory-ui/src/routes/app.runs.tsx b/apps/gittensory-ui/src/routes/app.runs.tsx index 6383d5e01e..9c1e524d52 100644 --- a/apps/gittensory-ui/src/routes/app.runs.tsx +++ b/apps/gittensory-ui/src/routes/app.runs.tsx @@ -502,7 +502,11 @@ function SavedViews({ current: { status: StatusFilter; kind: KindFilter; q: string }; onApply: (v: { status: StatusFilter; kind: KindFilter; q: string }) => void; }) { - const [views, setViews, hydrated] = useLocalStorage("gittensory.runs.views", []); + const [views, setViews, hydrated] = useLocalStorage( + "loopover.runs.views", + [], + "gittensory.runs.views", + ); const [naming, setNaming] = useState(false); const [name, setName] = useState(""); if (!hydrated) return null; diff --git a/apps/gittensory-ui/src/routes/app.workbench.tsx b/apps/gittensory-ui/src/routes/app.workbench.tsx index 9eec0295d5..5c4d438fc0 100644 --- a/apps/gittensory-ui/src/routes/app.workbench.tsx +++ b/apps/gittensory-ui/src/routes/app.workbench.tsx @@ -30,7 +30,11 @@ const LABELS: Record = { function Workbench() { const { tab } = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); - const [lastTab, setLastTab, hydrated] = useLocalStorage("gittensory.workbench.tab", "miner"); + const [lastTab, setLastTab, hydrated] = useLocalStorage( + "loopover.workbench.tab", + "miner", + "gittensory.workbench.tab", + ); const value: Tab = tab ?? lastTab; // Restore last tab into URL when no explicit ?tab= is present.