Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/gittensory-ui/src/components/site/api/try-it.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
18 changes: 16 additions & 2 deletions apps/gittensory-ui/src/components/site/api/try-it.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,21 @@
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<Storage, "getItem" | "setItem">): string {

Check warning on line 24 in apps/gittensory-ui/src/components/site/api/try-it.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
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;
Expand Down Expand Up @@ -52,7 +66,7 @@
const runRef = useRef<() => Promise<void>>(() => Promise.resolve());

useEffect(() => {
setToken(localStorage.getItem(STORAGE_KEY) ?? "");
setToken(readStoredSessionToken(localStorage));
setResult(null);
setError(null);
setPathParams({});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,15 @@ 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();
// render() flushes the synchronous hydration + load effects within its own act() wrapping (same
// 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(<OnboardingPreviewCard reviewability={REVIEWABILITY} />);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SettingsPreviewResponse | null>(null);
const [loading, setLoading] = useState(Boolean(target));
Expand Down Expand Up @@ -104,7 +108,7 @@ export function OnboardingPreviewCard({ reviewability }: { reviewability: Review
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="onboarding-preview-title" className="font-display text-token-lg font-semibold">
Here's what Gittensory would have flagged
Here's what LoopOver would have flagged
</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
{target ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export function NotificationReadinessCard() {
"/v1/app/notification-model",
"Notification model",
);
const [optIn, setOptIn] = useLocalStorage<boolean>("gittensory_notification_opt_in", false);
const [optIn, setOptIn] = useLocalStorage<boolean>(
"loopover_notification_opt_in",
false,
"gittensory_notification_opt_in",
);
const [busy, setBusy] = useState(false);

const permission = typeof Notification === "undefined" ? "unsupported" : Notification.permission;
Expand Down
58 changes: 58 additions & 0 deletions apps/gittensory-ui/src/lib/use-local-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>("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<string>("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<string>("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<string>("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<string>("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"));
});
});
19 changes: 16 additions & 3 deletions apps/gittensory-ui/src/lib/use-local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(key: string, initial: T) {
export function useLocalStorage<T>(key: string, initial: T, legacyKey?: string) {
const [value, setValue] = useState<T>(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)) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/gittensory-ui/src/routes/app.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ function OnboardingChecklist() {
const [state, setState, hydrated] = useLocalStorage<{
dismissed: boolean;
done: Record<string, boolean>;
}>("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 (
Expand Down
6 changes: 5 additions & 1 deletion apps/gittensory-ui/src/routes/app.runs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SavedView[]>("gittensory.runs.views", []);
const [views, setViews, hydrated] = useLocalStorage<SavedView[]>(
"loopover.runs.views",
[],
"gittensory.runs.views",
);
const [naming, setNaming] = useState(false);
const [name, setName] = useState("");
if (!hydrated) return null;
Expand Down
6 changes: 5 additions & 1 deletion apps/gittensory-ui/src/routes/app.workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ const LABELS: Record<Tab, string> = {
function Workbench() {
const { tab } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const [lastTab, setLastTab, hydrated] = useLocalStorage<Tab>("gittensory.workbench.tab", "miner");
const [lastTab, setLastTab, hydrated] = useLocalStorage<Tab>(
"loopover.workbench.tab",
"miner",
"gittensory.workbench.tab",
);
const value: Tab = tab ?? lastTab;

// Restore last tab into URL when no explicit ?tab= is present.
Expand Down
Loading