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
+102 -4
View File
@@ -51,9 +51,16 @@ vi.mock("../lib/api", () => ({
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
// The page hands Plex playback off to the Plex module; neither provider is under test here.
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
// The page hands Plex playback off to the Plex module. The provider itself is not under test, but
// WHAT is handed over is: the jump target decides both what plays and where Back goes.
const setPage = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./NavigationProvider", () => ({
useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }),
}));
vi.mock("./PlexProvider", () => ({
usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }),
}));
import Home from "./Home";
@@ -93,6 +100,39 @@ function ytCard(id: string, title: string, extra: Record<string, unknown> = {})
};
}
/** A Plex SHOW row of the resume rail, carrying the episode that would actually start — what the
* backend now attaches (`rails.continue_watching`). */
const showCard = () => ({
source: "plex",
kind: "show",
id: "show-1",
title: "A Show",
subtitle: null,
thumb: null,
aspect: "poster",
duration_seconds: null,
position_seconds: 0,
remaining_seconds: null,
status: "in_progress",
video: null,
plex: { id: "show-1", type: "show", title: "A Show", thumb: "" },
next: {
source: "plex",
kind: "episode",
id: "ep-7",
title: "Ep 7",
subtitle: "A Show",
thumb: null,
aspect: "wide",
duration_seconds: 1500,
position_seconds: 0,
remaining_seconds: 1500,
status: "new",
video: null,
plex: { id: "ep-7", type: "episode", title: "Ep 7", thumb: "" },
},
});
const payload = (heroId: string, tier = "resume") => ({
budget: "any",
hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) },
@@ -112,6 +152,8 @@ function mount() {
beforeEach(() => {
discover.mockReset();
discoverPick.mockReset();
setPage.mockReset();
setJumpTo.mockReset();
discover.mockResolvedValue(payload("v1"));
});
afterEach(cleanup);
@@ -160,7 +202,7 @@ describe("Home", () => {
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"]),
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
});
@@ -181,4 +223,60 @@ describe("Home", () => {
mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
});
it("plays a half-watched SHOW's next episode instead of opening the show page", async () => {
// Reported 2026-09-06. A show tile that opens the show page hands back the very decision this
// surface exists to remove — six seasons to choose between. The rail row carries `next`, and the
// click follows it.
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [showCard()],
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
});
it("hands off with `replace` so Back returns HERE, not to the Plex module's root", async () => {
// The hand-off is a continuation of the navigation that just pushed the Plex page entry, not a
// second step on top of it — without this, Back landed on the Plex Discover page the user never
// visited (and, under StrictMode, did nothing at all the first press).
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [showCard()],
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
expect(setPage).toHaveBeenCalledWith("plex");
});
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
// never once reaching the shelf. The press count travels to the server as the rotation.
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "comfort", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
);
await screen.findByText("Rerolled");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith(
"any",
expect.any(Number),
expect.any(Array),
2,
),
);
});
});
+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}`) }))}
/>
+5
View File
@@ -141,6 +141,11 @@ export default function PlexBrowse() {
: jumpTo.type === "play"
? { kind: "player", id: jumpTo.id }
: { kind: "info", id: jumpTo.id },
// A cross-module hand-off continues the navigation that brought us here rather than adding
// to it. It also makes this effect idempotent, which matters more than it looks: under
// StrictMode the effect body runs twice before `setJumpTo(null)` re-renders, and two pushes
// of the SAME sub-view left a duplicate entry whose Back visibly did nothing.
{ replace: jumpTo.replace },
);
setJumpTo(null);
}
+11 -1
View File
@@ -33,7 +33,17 @@ import { useNavigation } from "./NavigationProvider";
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
// landing page to avoid one hand-off would be the expensive way round.
export type PlexJumpTarget = { id: string; type: "movie" | "show" | "play" };
export type PlexJumpTarget = {
id: string;
type: "movie" | "show" | "play";
/** The jump ARRIVED WITH a page change — another module set this and sent the user here in one
* gesture (the Home surface plays a Plex title). The opened sub-view then REPLACES the page entry
* that hand-off just pushed, instead of stacking on top of it, so Back returns to where the user
* actually came from rather than to a module root they never visited. Omitted for a jump made
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
* sub-view the user really was looking at. */
replace?: boolean;
};
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)