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:
@@ -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}`) }))}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user