From f3c0b7be23be7de37ed2a755a06310d65dca79dd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 04:14:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(e24):=20the=20Home=20surface=20=E2=80=94?= =?UTF-8?q?=20one=20question,=20one=20answer,=20both=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the time-budget chips on top, one large hero that answers them, then the comfort shelf and a cross-source resume rail. Home is the landing — deliberately OVER the remembered last module, since landing on whatever you happened to close the app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an account back out (Settings toggle lands in S4). No third card component: each unified card carries the native feed / Plex payload, so the rails render through the existing VideoCard (`cardsSmall`) and PlexPosterCard, and `aspect` decides the tile shape without branching on source. Plex playback hands off to the Plex module via a new `jumpTo` type — the player lives there with its lazy hls.js and its provider tree, and mounting a second one on the landing page to save one hand-off would be the expensive way round. Four defects found by driving it in a real browser against the live account, each now pinned by a mutation-checked test: - **The reroll gave the same card back.** A new seed only re-draws the SURPRISE tier; the resume tier deterministically returns the most recently progressed thing that fits, so anyone with something half-finished pressed a dead button. `/pick` now takes an `exclude` list of `:` keys and the client keeps a rolling window of 8. Excluding a SHOW also excludes the episode it resolves to — otherwise the same suggestion comes back wearing a hat. - **"Nothing to suggest yet" flashed over a full library** whenever a reroll was in flight: the new query key has no data for a beat. An empty state standing in for a loading one is the dishonesty the house rules name outright. The hero now falls back to the loaded payload while a pick is pending, and only trusts a null once the pick has RESOLVED. - **A budget change replaced the page — chips included — with a spinner**, so the control you just clicked vanished under your cursor. `keepPreviousData`, the same reason the feed uses it. - **The hero printed twice**, once as itself and once as the first resume tile. Two smaller UI calls made from the same session: the budget caption sits NEXT to its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem page throws the answers to the far edge; and the feed takes the YouTube mark its own toolbar already uses, since Home now owns the house icon. Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780, pytest 989. Verified end to end on localdev against the real account: budget chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is a ten-minute answer), the reroll walks three distinct heroes, and a Plex show tile opens its drill-down in the Plex module. --- backend/app/discover/cards.py | 7 + backend/app/discover/hero.py | 27 +- backend/app/routes/discover.py | 18 +- backend/tests/test_discover.py | 56 +++ frontend/src/components/Home.dom.test.tsx | 184 ++++++++ frontend/src/components/Home.tsx | 446 ++++++++++++++++++ frontend/src/components/PlexBrowse.tsx | 6 +- frontend/src/components/PlexProvider.tsx | 9 +- frontend/src/components/moduleRegistry.tsx | 2 + .../src/components/navigation.dom.test.ts | 17 +- frontend/src/components/navigationInit.ts | 9 +- frontend/src/i18n/locales/en/home.json | 37 ++ frontend/src/i18n/locales/hu/home.json | 37 ++ frontend/src/lib/api.ts | 4 + frontend/src/lib/api/discover.ts | 98 ++++ frontend/src/lib/api/facade.test.ts | 1 + frontend/src/lib/modules.ts | 7 +- frontend/src/lib/pageMeta.test.ts | 1 + frontend/src/lib/queryKeys.ts | 6 + frontend/src/lib/storage.ts | 5 + frontend/src/lib/urlState.ts | 1 + 21 files changed, 964 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/Home.dom.test.tsx create mode 100644 frontend/src/components/Home.tsx create mode 100644 frontend/src/i18n/locales/en/home.json create mode 100644 frontend/src/i18n/locales/hu/home.json create mode 100644 frontend/src/lib/api/discover.ts diff --git a/backend/app/discover/cards.py b/backend/app/discover/cards.py index 9b4f9da1..336e917f 100644 --- a/backend/app/discover/cards.py +++ b/backend/app/discover/cards.py @@ -59,6 +59,13 @@ def _base( } +def key(card: dict) -> str: + """The identity of a card ACROSS sources — `:`. A Plex rating_key and a YouTube video + id could in principle collide as bare strings, and the reroll's exclusion list has to be able to + say "not that one" about either.""" + return f"{card['source']}:{card['id']}" + + def from_video(row: dict) -> dict: """`row` is a `feedquery.serialize` dict — the same object the feed grid renders.""" card = _base( diff --git a/backend/app/discover/hero.py b/backend/app/discover/hero.py index b9b7b086..c124c45c 100644 --- a/backend/app/discover/hero.py +++ b/backend/app/discover/hero.py @@ -29,6 +29,10 @@ from app.plex.lookups import by_rating_key HERO_TIERS = ("resume", "comfort", "surprise") +# How deep the surprise tier draws before giving up. The client's exclusion window is 8 (see +# `Home.tsx`), so a draw of 12 can always step past a full window of already-shown picks. +SURPRISE_DRAW = 12 + def _playable(db: Session, uid: int, card: dict) -> dict | None: """A hero has to have a Play button, so a Plex SHOW is resolved to the episode that would start. @@ -50,22 +54,37 @@ def pick( *, resume: list[dict] | None = None, shelf: list[dict] | None = None, + exclude: set[str] | None = None, ) -> dict | None: """`{"tier": ..., "card": ...}`, or None when nothing at all fits. `resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does not build the same two rails twice; the reroll endpoint passes neither and pays only for what it - reaches.""" + reaches. + + `exclude` is what makes "Mást kérek" mean something. A new seed alone only re-draws the SURPRISE + tier: the resume tier deterministically returns the most recently progressed thing that fits, so + pressing reroll on a user who has something half-finished handed back the identical card and the + button looked dead (measured in the browser, 2026-09-06). Skipping the cards already shown makes + every press produce a different answer, at every tier.""" + skip = exclude or set() for card in rails.continue_watching(db, user) if resume is None else resume: playable = _playable(db, user.id, card) + # Excluded on the card the USER saw (the show), not only on what it resolves to, so a + # rerolled-past show does not come straight back as its own next episode. + if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip): + continue if playable is not None and fits(playable["remaining_seconds"], budget): return {"tier": "resume", "card": playable} for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf): - if fits(card["remaining_seconds"], budget): + if cards.key(card) not in skip and fits(card["remaining_seconds"], budget): return {"tier": "comfort", "card": card} - for card in rails.surprise(db, user, budget, seed, limit=1): - return {"tier": "surprise", "card": card} + # A wider draw than the one card we need: the shuffle is seeded, so without the headroom an + # excluded pick would leave the tier empty rather than move past it. + for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW): + if cards.key(card) not in skip: + return {"tier": "surprise", "card": card} return None diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index 8ba888f2..b9cf4e92 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -60,18 +60,32 @@ def get_discover( } +# How many already-shown cards a reroll may carry. The client keeps a rolling window (see +# `Home.tsx`); the cap is here so a crafted URL cannot turn the exclusion list into an unbounded scan. +MAX_EXCLUDE = 32 + + @router.get("/pick") def get_pick( budget: Budget = DEFAULT_BUDGET, seed: int = 0, + exclude: str = "", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget. One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the - Plex half while the YouTube half stood still, and the button would look broken half the time.""" - return {"budget": budget, "hero": hero_mod.pick(db, user, budget, seed)} + Plex half while the YouTube half stood still, and the button would look broken half the time. + + `exclude` is a comma-separated list of `:` keys the user has already been shown. It + is what makes the button honest: a seed alone only moves the surprise tier, so a user with + something half-finished got the same card back every press.""" + skip = {p for p in exclude.split(",") if p} + return { + "budget": budget, + "hero": hero_mod.pick(db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE])), + } @router.get("/pins") diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index 318f5301..1b51d191 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -356,6 +356,62 @@ def test_the_seed_decides_which_source_leads(db): assert leaders == {"youtube", "plex"} +def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db): + """A new seed alone only re-draws the SURPRISE tier — the resume tier deterministically returns + the most recently progressed thing that fits, so without an exclusion the reroll handed the same + card straight back and the button looked dead (found in the browser, 2026-09-06).""" + user, _ = _seed_yt(db) + db.add_all( + [ + VideoState( + user_id=user.id, + video_id="vmed", + status="new", + position_seconds=300, + progress_updated_at=NOW, + ), + VideoState( + user_id=user.id, + video_id="vshort", + status="new", + position_seconds=60, + progress_updated_at=NOW - timedelta(hours=1), + ), + ] + ) + db.commit() + + first = hero.pick(db, user, "any", seed=1) + assert first["card"]["id"] == "vmed" + # Same seed, same everything — only the exclusion differs. + second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"}) + assert second["card"]["id"] == "vshort" + + +def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db): + """The user rerolled past the SHOW they were shown; handing back its next episode under a + different title would be the same suggestion wearing a hat.""" + user, _ = _seed_yt(db) + _, show, ep1 = _seed_plex(db, user) + _enable_plex(db) + db.add( + PlexState( + user_id=user.id, + item_id=ep1.id, + status="new", + position_seconds=120, + progress_updated_at=NOW, + ) + ) + db.commit() + + shown = hero.pick(db, user, "any", seed=1) + assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode + # Excluding what the USER saw — the show — must move the tier on, not return the episode again. + after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"}) + assert after is None or after["card"]["id"] != "ps1e1" + + # ── comfort shelf ───────────────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx new file mode 100644 index 00000000..1b2e5e1d --- /dev/null +++ b/frontend/src/components/Home.dom.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +// +// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of +// which is invisible to tsc and to a render-only check: +// • the hero's kicker comes from the SERVER's tier, never from the card's shape; +// • the hero is not printed twice (once as the hero, once as the first resume tile); +// • a budget change asks for the new budget and keeps the old answer on screen meanwhile; +// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the +// identical card straight back and the button looks dead. +// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside +// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.time ? `${key}:${opts.time}` : key, + i18n: { language: "en" }, + }), + }; +}); + +const discover = vi.fn(); +const discoverPick = vi.fn(); +vi.mock("../lib/api", () => ({ + api: { + discover: (...a: unknown[]) => discover(...a), + discoverPick: (...a: unknown[]) => discoverPick(...a), + setState: () => Promise.resolve(), + watchLaterAdd: () => Promise.resolve(), + watchLaterRemove: () => Promise.resolve(), + }, + 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() }) })); + +import Home from "./Home"; + +function ytCard(id: string, title: string, extra: Record = {}) { + return { + source: "youtube", + kind: "video", + id, + title, + subtitle: "A channel", + thumb: null, + aspect: "wide", + duration_seconds: 600, + position_seconds: 120, + remaining_seconds: 480, + status: "new", + plex: null, + video: { + id, + title, + channel_id: "UC1", + channel_title: "A channel", + channel_thumbnail: null, + channel_url: "https://youtube.com/channel/UC1", + published_at: "2026-01-01T00:00:00Z", + thumbnail_url: null, + duration_seconds: 600, + view_count: 10, + is_short: false, + live_status: "none", + status: "new", + position_seconds: 120, + saved: false, + watch_url: `https://www.youtube.com/watch?v=${id}`, + }, + ...extra, + }; +} + +const payload = (heroId: string, tier = "resume") => ({ + budget: "any", + hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) }, + continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")], + comfort: [], +}); + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(() => { + discover.mockReset(); + discoverPick.mockReset(); + discover.mockResolvedValue(payload("v1")); +}); +afterEach(cleanup); + +describe("Home", () => { + it("labels the hero from the server's tier, not from the card", async () => { + // The same card shape is served under a `surprise` tier: a client that inferred "where you left + // off" from the resume position would get this wrong, which is the dishonest state we forbid. + discover.mockResolvedValue(payload("v1", "surprise")); + mount(); + expect(await screen.findByText("home.hero.surprise")).toBeTruthy(); + expect(screen.queryByText(/home\.hero\.resume/)).toBeNull(); + }); + + it("interpolates the remaining time into the resume kicker", async () => { + mount(); + // 480s left → 8:00, passed through the key-passthrough t() as `: