fix(home): Back keeps the question, and the focus finds its card by name

Opening a card left Home, and Back came back to "Doesn't matter" with a
different hero, whose card then dropped out of the Continue rail and moved
the keyboard onto its neighbour. The budget, reroll seed and seen list live
on the history entry now, and a returning list focuses the item by its
name before its place.
This commit is contained in:
2026-09-19 04:51:43 +02:00
parent 6096dd6927
commit 250e3a9f90
5 changed files with 119 additions and 16 deletions
+26
View File
@@ -163,6 +163,8 @@ function mountWithClient() {
}
beforeEach(() => {
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
window.history.replaceState(null, "");
discover.mockReset();
discoverPick.mockReset();
setPage.mockReset();
@@ -553,11 +555,35 @@ describe("Home", () => {
first.unmount();
discoverPick.mockClear();
// A later visit is a new history entry (setPage pushes a fresh one); the same entry would bring
// the rerolled question back on purpose (see the Back test below).
window.history.pushState(null, "");
mount();
await screen.findByText("Hero v1");
expect(discoverPick).not.toHaveBeenCalled();
});
// Prod UAT 2026-09-19: a card opened from Home leaves the page, and Back came back to "Doesn't
// matter" and a different hero. The question is kept on the entry, so Back asks it again.
it("Back to the same entry brings back the budget and the rerolled hero", async () => {
discoverPick.mockResolvedValue({
budget: "short",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
const first = mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.short"));
fireEvent.click(screen.getByText("home.hero.reroll"));
expect(await screen.findByText("Rerolled")).toBeTruthy();
first.unmount();
discover.mockClear();
mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discover).toHaveBeenLastCalledWith("short", 0);
});
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
@@ -149,6 +149,8 @@ const item = (label: string) => {
};
beforeEach(() => {
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
window.history.replaceState(null, "");
discover.mockReset().mockResolvedValue(payload());
comfortPins.mockReset().mockResolvedValue({ items: shelf });
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
+36 -4
View File
@@ -348,26 +348,58 @@ function Hero({
);
}
const SAVED_KEY = "sfHome";
interface SavedQuestion {
budget: DiscoverBudget;
seed: number;
press: number;
seen: string[];
}
/** The question this history entry was left with, if it was left from Home. Read from
* `history.state`, which only this app writes — still checked field by field. */
function readSavedQuestion(): SavedQuestion | null {
const raw = (window.history.state as Record<string, unknown> | null)?.[SAVED_KEY] as
| Partial<SavedQuestion>
| undefined;
if (!raw || !DISCOVER_BUDGETS.includes(raw.budget as DiscoverBudget)) return null;
return {
budget: raw.budget as DiscoverBudget,
seed: Number.isInteger(raw.seed) ? (raw.seed as number) : 0,
press: Number.isInteger(raw.press) ? (raw.press as number) : 0,
seen: Array.isArray(raw.seen) ? raw.seen.filter((k) => typeof k === "string") : [],
};
}
export default function Home() {
const { t } = useTranslation();
const qc = useQueryClient();
const { setPage } = useNavigationActions();
const { setJumpTo } = usePlexActions();
const [budget, setBudget] = useState<DiscoverBudget>("any");
// The question on screen lives on this page's history ENTRY as well: opening a card leaves Home
// (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card
// then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19).
const [saved] = useState(readSavedQuestion);
const [budget, setBudget] = useState<DiscoverBudget>(saved?.budget ?? "any");
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
// pressing the button does not refetch the rails under the user's cursor.
const [seed, setSeed] = useState(0);
const [seed, setSeed] = useState(saved?.seed ?? 0);
// Which press this is. It rotates the tier the server leads with, so "Mást kérek" offers a
// different KIND of answer each time (where you left off → your shelf → a surprise) rather than
// the next row of the same rail. Reset with the budget: a new question starts the rotation over.
const [press, setPress] = useState(0);
const [press, setPress] = useState(saved?.press ?? 0);
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
// the whole history: an unbounded list would eventually exclude everything and leave the button
// with nothing to give, whereas a bounded one cannot. Its size is `SEEN_WINDOW` — see there for
// why it is 32 and not the 8 this comment used to quote. Kept here (not in the URL or a pref)
// because it is a property of this sitting.
const [seen, setSeen] = useState<string[]>([]);
const [seen, setSeen] = useState<string[]>(saved?.seen ?? []);
useEffect(() => {
const question: SavedQuestion = { budget, seed, press, seen };
window.history.replaceState({ ...window.history.state, [SAVED_KEY]: question }, "");
}, [budget, seed, press, seen]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
const q = useQuery({