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