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
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
//
// The Home surface's slice builds its own query strings, and the Home DOM tests mock this module —
// so nothing else in the suite ever looks at the URL that actually goes out. That gap hid a live
// parameter once already: the reroll's `start` reached `api.discoverPick` and stopped there.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { discoverApi } from "./discover";
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({}),
text: async () => "{}",
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
// Indexed, not `.at(-1)`: the e2e tsconfig's lib target predates it, and the gate typechecks three
// projects.
const lastUrl = (): string => {
const calls = fetchMock.mock.calls;
return String(calls[calls.length - 1]?.[0]);
};
describe("discoverApi query strings", () => {
it("sends the press count as `start` — the tier rotation lives on the wire", async () => {
await discoverApi.discoverPick("medium", 42, [], 3);
expect(lastUrl()).toContain("start=3");
expect(lastUrl()).toContain("budget=medium");
expect(lastUrl()).toContain("seed=42");
});
it("sends the press count even on the first press, so `start` is never implied", async () => {
await discoverApi.discoverPick("any", 7);
expect(lastUrl()).toContain("start=0");
});
it("encodes the exclusion list, whose keys carry a colon", async () => {
await discoverApi.discoverPick("any", 1, ["youtube:abc", "plex:99"], 2);
expect(lastUrl()).toContain(`exclude=${encodeURIComponent("youtube:abc,plex:99")}`);
});
it("omits `exclude` entirely when there is nothing to exclude", async () => {
await discoverApi.discoverPick("any", 1, [], 0);
expect(lastUrl()).not.toContain("exclude=");
});
});
+4 -1
View File
@@ -70,13 +70,16 @@ export const discoverApi = {
// user's cursor.
// `exclude` is the rolling list of `<source>:<id>` keys already shown. Without it a reroll only
// moves the surprise tier, so a user with something half-finished gets the same hero back.
// `press` is which press this is; it rotates the tier the server answers from, so consecutive
// presses offer a different KIND of answer instead of walking down the resume rail.
discoverPick: (
budget: DiscoverBudget,
seed: number,
exclude: string[] = [],
press = 0,
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
req(
`/api/discover/pick?budget=${budget}&seed=${seed}` +
`/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` +
(exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""),
),
comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"),
@@ -76,6 +76,42 @@ describe("useHistorySubview open", () => {
expect(window.history.state?._sub).toBe("root");
expect(window.location.search).toBe(""); // ?v dropped
});
// E24: a cross-module hand-off (Home plays a Plex title) arrives on an entry the page navigation
// just pushed. Opening the sub-view on TOP of it stranded the user on a module root they never
// visited — browser-Back from the player landed on the Plex Discover page instead of Home
// (measured 2026-09-06). `replace` makes the sub-view BE that entry.
it("replace rewrites the current entry instead of stacking a new one", () => {
const before = window.history.length;
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a", { replace: true }));
expect(result.current.view).toBe("a");
expect(window.history.state?._sub).toBe("a");
expect(window.location.search).toBe("?v=a");
expect(window.history.length).toBe(before); // no new entry to Back through
});
it("replace is idempotent — two opens of the same view are still one entry", () => {
// Not a nicety: under StrictMode the jumpTo effect body runs TWICE before its `setJumpTo(null)`
// re-renders, and two pushes of the identical sub-view left a duplicate whose Back visibly did
// nothing.
const before = window.history.length;
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => {
result.current.open("a", { replace: true });
result.current.open("a", { replace: true });
});
expect(window.history.length).toBe(before);
});
it("keeps the entry's own sfScroll id on a replace", () => {
// It is the SAME entry, so it keeps its scroll identity; PageScroller keys offsets per
// (entry, view), and the view key changed, so the sub-view still opens at the top.
setUrl("/", { sfScroll: 7 });
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a", { replace: true }));
expect(window.history.state?.sfScroll).toBe(7);
});
});
describe("useHistorySubview popstate", () => {
+13 -2
View File
@@ -81,7 +81,10 @@ export function useHistorySubview<T>(
url?: SubviewUrl<T>,
): {
view: T;
open: (next: T) => void;
/** `replace` rewrites the CURRENT entry instead of stacking a new one — for a sub-view that is the
* continuation of the navigation that just arrived here, not a step the user took inside the
* module. See the `jumpTo` hand-off in PlexBrowse. */
open: (next: T, opts?: { replace?: boolean }) => void;
back: () => void;
} {
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
@@ -110,9 +113,17 @@ export function useHistorySubview<T>(
}, []);
return {
view,
open: (next: T) => {
open: (next: T, opts?: { replace?: boolean }) => {
setView(next);
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
if (opts?.replace) {
// Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and
// the view key has changed, so the sub-view still opens at the top without minting an id
// that would orphan the one this entry already carries.
window.history.replaceState({ ...window.history.state, _sub: next }, "", href);
notifyNav();
return;
}
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
window.history.pushState(