fix(e24): the Home surface's three UAT findings — Back, the reroll, and a show card

All three were measured before they were touched, and the diagnosis moved twice.

**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.

A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.

**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.

The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.

**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.

Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.

Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.

Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
This commit is contained in:
2026-09-06 05:38:28 +02:00
parent ccd5f3739a
commit 82914b56ca
13 changed files with 463 additions and 68 deletions
+27 -7
View File
@@ -61,9 +61,12 @@ const BUDGETS: readonly DiscoverBudget[] = ["short", "medium", "long", "any"];
// drift from it.
const YT_SLOT = 200;
// How many already-offered cards the reroll remembers. Matched by the server's own draw depth
// (`hero.SURPRISE_DRAW`), which has to be able to step past a full window.
const SEEN_WINDOW = 8;
// How many already-offered cards the reroll remembers. Matched to the server's own cap
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
// before the resume rail (20 rows) was exhausted, so the button cycled the same nine cards forever
// (measured, 2026-09-06). It is bounded at all only because the server will not scan an unbounded
// exclusion list.
const SEEN_WINDOW = 32;
// The four shelf moves, in the order they appear in the menu.
const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
@@ -337,6 +340,10 @@ export default function Home() {
// 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);
// 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);
// 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 window of 8 can never exhaust a library and only repeats after
@@ -353,8 +360,8 @@ export default function Home() {
placeholderData: keepPreviousData,
});
const pickQ = useQuery({
queryKey: qk.discoverPick(budget, seed, seen),
queryFn: () => api.discoverPick(budget, seed, seen),
queryKey: qk.discoverPick(budget, seed, seen, press),
queryFn: () => api.discoverPick(budget, seed, seen, press),
enabled: seed !== 0,
staleTime: 0,
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
@@ -415,7 +422,14 @@ export default function Home() {
return;
}
if (target.plex) {
setJumpTo({ id: target.plex.id, type: target.plex.type === "show" ? "show" : "play" });
// `replace`: the Plex module's sub-view rewrites the page entry `setPage` is about to push,
// so browser-Back from the player returns HERE — not to the Plex Discover root, which this
// navigation passed through and the user never saw.
setJumpTo({
id: target.plex.id,
type: target.plex.type === "show" ? "show" : "play",
replace: true,
});
setPage("plex");
}
},
@@ -427,7 +441,11 @@ export default function Home() {
const onInfo = useCallback(
(card: DiscoverCard) => {
if (!card.plex) return;
setJumpTo({ id: card.plex.id, type: card.plex.type === "show" ? "show" : "movie" });
setJumpTo({
id: card.plex.id,
type: card.plex.type === "show" ? "show" : "movie",
replace: true,
});
setPage("plex");
},
[setJumpTo, setPage],
@@ -485,6 +503,7 @@ export default function Home() {
// would look like a dead button).
if (heroKey)
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
setPress((n) => n + 1);
setSeed((prev) => {
let next = prev;
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
@@ -515,6 +534,7 @@ export default function Home() {
setBudget(v);
setSeed(0); // the payload for the new budget carries its own hero
setSeen([]); // a new question deserves the whole library again
setPress(0); // …and the tier rotation starts from the page's own priority order
}}
options={BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
/>