fix(home): shelf honours the Plex gate; hero unpins its origin; palette asks for a surprise

- A13: with Plex off, the Home shelf and the hero's comfort tier go YouTube-only like the rails
  beside them (owner's call: hide the tiles). The pins are kept; GET /pins still lists them.
- Reorder deals the listed pins into the slots they already hold, so a partial list (hidden Plex
  pins, a stale tab) no longer puts two pins on one position; repeats are ignored.
- A21: a comfort hero carries `pinned_from`, the shelf entry it was resolved from; its menu acts on
  that pin, so a pinned show's next episode offers "Unpin" instead of creating a second pin.
- B12: the palette's "Meglepetés" asks for the surprise tier (the next press leading with it); the
  rotation carries on from there.
- B13: the surprise token names the history entry it was requested for (minted by setPage's new
  `then`, after the push) and is dropped on any other entry, so a navigation that never mounted
  Home cannot replay it later.
This commit is contained in:
2026-09-19 17:07:11 +02:00
parent c6bf84a578
commit 7b94e9a651
15 changed files with 291 additions and 60 deletions
+5 -1
View File
@@ -81,7 +81,11 @@ def pick(
return None
def _comfort() -> dict | None:
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
if shelf is None:
own = pins.shelf(db, user, include_plex=rails.plex_available(db))
else:
own = shelf
for card in pins.playables(own):
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
return card
return None
+52 -4
View File
@@ -75,6 +75,31 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
def reorder(db: Session, user: User, ids: list[int]) -> None:
"""Put the listed pins in the listed order, WITHIN the slots they already hold.
The client sends the order it can SEE, which is not always the whole shelf: with Plex off the Home
rail hides the Plex pins, and a stale tab may not know a pin added elsewhere. Numbering only the
listed ids 0..n-1 collided with every pin left out — two tiles on one position, and the hidden
ones jumped about when they came back. So the pins left out keep their slots, and the listed ones
are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are
ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
current = list_pins(db, user)
owned = {p.id for p in current}
listed: list[int] = []
for i in ids:
if i in owned and i not in listed:
listed.append(i)
chosen = set(listed)
queue = iter(listed)
order = [next(queue) if p.id in chosen else p.id for p in current]
by_id = {p.id: p for p in current}
for position, pin_id in enumerate(order):
if by_id[pin_id].position != position:
by_id[pin_id].position = position
db.flush()
def list_pins(db: Session, user: User) -> list[ComfortPin]:
return list(
db.execute(
@@ -188,10 +213,21 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict
return card
def shelf(db: Session, user: User) -> list[dict]:
"""The whole shelf, in the user's own order."""
_PLEX_COLUMNS = ("plex_item_id", "plex_show_id")
def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]:
"""The whole shelf, in the user's own order.
`include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call,
2026-09-19): the rails beside it go YouTube-only, and a Plex tile there would offer a Play that
lands on a gated module. The pins themselves are kept — the pin menus still read the full shelf
(`GET /pins`), and switching Plex back on brings the tiles back in their old slots
(`reorder` keeps a hidden pin's place)."""
out = []
pins = list_pins(db, user)
if not include_plex:
pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)]
batch = _Batch.load(db, user, pins)
for pin in pins:
card = _shelf_card(db, user, pin, batch)
@@ -202,5 +238,17 @@ def shelf(db: Session, user: User) -> list[dict]:
def playables(shelf_cards: list[dict]) -> list[dict]:
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
with nothing unwatched left, or an empty playlist, simply is not an answer right now."""
return [c["next"] for c in shelf_cards if c.get("next")]
with nothing unwatched left, or an empty playlist, simply is not an answer right now.
Each carries `pinned_from`: the pin it was resolved from. A show's next EPISODE is not itself
pinned, so without it the hero's menu looked the episode up, missed, and offered "Pin" under a
kicker that says "From your shelf" — and taking it inserted a second pin. With it, the hero's
pin row acts on the shelf entry the card came from (the owner's call, 2026-09-19: "Unpin")."""
return [
{
**c["next"],
"pinned_from": {k: c[k] for k in ("source", "kind", "id", "title")},
}
for c in shelf_cards
if c.get("next")
]
+8 -13
View File
@@ -13,7 +13,7 @@ from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -53,7 +53,9 @@ def get_discover(
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
are not queried twice for a single page load."""
resume = rails.continue_watching(db, user)
shelf = pins.shelf(db, user)
# Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails
# beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`).
shelf = pins.shelf(db, user, include_plex=rails.plex_available(db))
return {
"budget": budget,
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
@@ -180,18 +182,11 @@ def reorder_pins(
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Rewrite the shelf order from a full list of pin ids.
"""Rewrite the shelf order from the list of pin ids the client shows.
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
deleted meanwhile should reorder what still exists, not fail wholesale."""
owned = {p.id for p in pins.list_pins(db, user)}
for position, pin_id in enumerate(i for i in body.ids if i in owned):
db.execute(
update(ComfortPin)
.where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
.values(position=position)
)
tiles claiming the same slot the way an interrupted sequence of single moves can. The list may be
PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place."""
pins.reorder(db, user, body.ids)
db.commit()
return {"ok": True}
+62
View File
@@ -1007,3 +1007,65 @@ def test_deleting_a_pin_reports_what_went(db, as_user):
r = c.delete(f"/api/discover/pins/{pin_id}")
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
assert c.get("/api/discover/pins").json()["items"] == []
def test_with_plex_off_the_home_shelf_hides_plex_pins_but_keeps_them(db, as_user):
"""The owner's call (2026-09-19): with the deployment's Plex toggle off, the Home shelf goes
YouTube-only like the rails beside it — a Plex tile there offered a Play that landed on a gated
module. The hero's comfort tier draws from the same filtered shelf, and the pin menus' own
listing still sees every pin, so nothing the user curated is lost."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
db.add_all(
[
ComfortPin(
user_id=user.id,
plex_item_id=db.scalar(select(PlexItem.id).where(PlexItem.rating_key == "pm1")),
position=0,
),
ComfortPin(user_id=user.id, video_id="vlong", position=1),
]
)
db.commit()
c = as_user(user)
assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["vlong"]
assert c.get("/api/discover/pick?start=1").json()["hero"]["card"]["id"] == "vlong"
assert {t["id"] for t in c.get("/api/discover/pins").json()["items"]} == {"pm1", "vlong"}
_enable_plex(db)
assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["pm1", "vlong"]
def test_a_reorder_of_the_visible_pins_keeps_the_hidden_ones_in_place(db, as_user):
"""The client sends the order it SHOWS. With Plex off that leaves the Plex pins out, and
numbering only the listed ids 0..n-1 put two pins on one position and moved the hidden one about
when Plex came back. The listed pins trade the slots they hold; the rest stay put."""
user, ch = _seed_yt(db)
_seed_plex(db, user)
c = as_user(user)
_enable_plex(db)
a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
hidden = c.post("/api/discover/pins", json={"kind": "plex_item", "ref": "pm1"}).json()["id"]
b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
assert c.post("/api/discover/pins/reorder", json={"ids": [b, b, a]}).status_code == 200
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, hidden, a]
positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == user.id)).all()
assert sorted(positions) == [0, 1, 2]
def test_a_hero_from_a_show_pin_names_the_pin_it_came_from(db, as_user):
"""A show's next EPISODE is not itself pinned, so the hero's menu used to look the episode up,
miss, and offer "Pin" under "From your shelf" — creating a second pin. The card now carries
the pin it was resolved from, which the menu acts on (owner's call: "Unpin")."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
_enable_plex(db)
c = as_user(user)
assert c.post("/api/discover/pins", json={"kind": "plex_show", "ref": "ps1"}).status_code == 200
got = c.get("/api/discover/pick?start=1").json()["hero"]
assert got["tier"] == "comfort" and got["card"]["id"] == "ps1e1"
assert got["card"]["pinned_from"] == {
"source": "plex",
"kind": "show",
"id": "ps1",
"title": "A Show",
}
@@ -135,10 +135,11 @@ export default function AppCommandPalette() {
label: t("palette.actions.surprise"),
icon: <Sparkles className="w-4 h-4" />,
keywords: t("home.title"),
run: () => {
setPage("home");
requestSurprise();
},
// The token is minted on the entry the push made (see lib/homeIntent), and asks for the
// SURPRISE tier: the command is labelled "Meglepetés", and the reroll's plain next press
// is answered by whichever tier the rotation reaches — for anyone with a shelf, "A
// polcodról" under a command that promised a surprise (owner's call, 2026-09-19).
run: () => setPage("home", requestSurprise),
},
{
id: "action:shortcuts",
+33 -2
View File
@@ -52,6 +52,7 @@ vi.mock("../lib/api", () => ({
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
HERO_TIERS: ["resume", "comfort", "surprise"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
@@ -609,8 +610,38 @@ describe("Home", () => {
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
// …and on the press count the server reads as "which tier answers first".
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
// …and on the press count the server reads as "which tier answers first": 2 leads with the
// SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1)
// led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19).
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2);
});
// The rotation carries on from the requested tier: the page's own button after a palette surprise
// is the NEXT tier (resume), not a second surprise and not a jump back to press 1.
it("lets the button's next press carry the rotation on from the requested tier", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(discoverPick.mock.lastCall?.[3]).toBe(3);
});
// A token minted for a navigation that never mounted Home (a failed lazy chunk, a Back before
// the module arrived) must not replay on the NEXT visit: it names the history entry it was
// requested for, and Home mounting on any other entry drops it.
it("drops a surprise request that was made for another history entry", async () => {
requestSurprise();
window.history.pushState(null, "");
mount();
await screen.findByText("Hero v1");
expect(discoverPick).not.toHaveBeenCalled();
// …and it is spent, not merely skipped: the entry it named does not revive it either.
expect(takeSurprise()).toBe(false);
});
it("says the shelf is empty rather than hiding it", async () => {
@@ -61,6 +61,7 @@ vi.mock("../lib/api", () => ({
},
HttpError: class extends Error {},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
HERO_TIERS: ["resume", "comfort", "surprise"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
@@ -302,6 +303,34 @@ describe("Home — the comfort shelf's context menu", () => {
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
});
// A comfort hero resolved from a pinned SHOW is that show's next episode, which is not itself
// pinned: the menu looked the episode up, missed, and offered "Pin" under "From your shelf" —
// taking it created a second pin. The card now names the pin it came from, and the row acts on
// THAT (owner's call, 2026-09-19: "Unpin").
it("unpins the SHOW a comfort hero's episode was resolved from", async () => {
const show = {
...base("ps1", "Pinned show"),
source: "plex",
kind: "show",
video: null,
pin_id: 21,
};
const episode = {
...base("ps1e2", "Next episode"),
source: "plex",
kind: "episode",
video: null,
pinned_from: { source: "plex", kind: "show", id: "ps1", title: "Pinned show" },
};
discover.mockResolvedValue({ ...payload(), hero: { tier: "comfort", card: episode } });
comfortPins.mockResolvedValue({ items: [...shelf, show] });
mount();
fireEvent.contextMenu(await screen.findByText("Next episode"));
fireEvent.click(await waitFor(() => item("home.pin.remove")));
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(21));
expect(addComfortPin).not.toHaveBeenCalled();
});
it("says so rather than guessing while the shelf is still loading", async () => {
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
// trustworthy once the shelf has answered.
+38 -18
View File
@@ -18,6 +18,7 @@ import {
type DiscoverBudget,
type DiscoverCard,
type DiscoverPayload,
HERO_TIERS,
type HeroTier,
type PlexCard,
type Video,
@@ -350,7 +351,14 @@ function Hero({
</Button>
</div>
</div>
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => c.title} />
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
The owner's call (2026-09-19): the row unpins the show. */}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
title={(c) => (c.pinned_from ?? c).title}
/>
</section>
);
}
@@ -654,28 +662,40 @@ export default function Home() {
[budget, qc, t],
);
const reroll = useCallback(() => {
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
// 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;
return next;
});
}, [heroKey]);
const reroll = useCallback(
(lead?: HeroTier) => {
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
// would look like a dead button).
if (heroKey)
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
// `lead` asks for a tier: the next press whose rotation leads with it. The rotation carries on
// from there, so the button's following press is the tier after it, as it would have been.
setPress((n) => {
let next = n + 1;
// Guarded on membership: a click handler passed straight in would hand over its event
// as `lead`, and this loop would never end.
if (lead && HERO_TIERS.includes(lead))
while (HERO_TIERS[next % HERO_TIERS.length] !== lead) next += 1;
return next;
});
setSeed((prev) => {
let next = prev;
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
return next;
});
},
[heroKey],
);
// "Meglepetés" from the command palette: the request is made on another page, so it waits as a
// token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
// is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
// false to every later pass, including StrictMode's second mount invocation and the re-runs
// `reroll`'s changing identity causes.
// `reroll`'s changing identity causes — and to a token minted for another history entry.
const surpriseRequest = useSurpriseRequest();
useEffect(() => {
if (surpriseRequest && takeSurprise()) reroll();
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
}, [surpriseRequest, reroll]);
if (q.isPending) return <LoadingState />;
@@ -716,7 +736,7 @@ export default function Home() {
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
onReroll={reroll}
onReroll={() => reroll()}
rerolling={rerolling}
/>
) : (
@@ -743,7 +763,7 @@ export default function Home() {
type="button"
icon={<RotateCcw className="h-4 w-4" />}
loading={rerolling}
onClick={reroll}
onClick={() => reroll()}
>
{t("home.hero.reroll")}
</Button>
@@ -156,6 +156,23 @@ describe("NavigationProvider · never under an open overlay", () => {
expect(window.history.state).toMatchObject({ sfPage: "plex" });
});
// `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so
// over an open player it must wait for the push, and with nothing to push it still runs.
it("runs setPage's `then` after the push, on the entry the push made", async () => {
const { result } = render();
await openOverlay();
const seen: unknown[] = [];
expect(result.current.page).not.toBe("plex");
act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage)));
expect(seen).toEqual([]);
await settle();
await settle();
expect(seen).toEqual(["plex"]);
act(() => result.current.setPage("plex", () => seen.push("again")));
expect(seen).toEqual(["plex", "again"]);
});
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
it("setPage to the current page closes the overlay and pushes nothing", async () => {
@@ -57,13 +57,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
channelViewRef.current = channelView;
const setPage = useCallback(
(next: Page) =>
(next: Page, then?: () => void) =>
// Never under an open overlay — see the interface doc. The refs are read when the push
// actually happens, which over an open player is a moment after this call.
closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) return;
if (next === pageRef.current && !channelViewRef.current) {
then?.();
return;
}
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
@@ -95,6 +98,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}),
);
notifyNav();
then?.();
}),
[],
);
+3 -1
View File
@@ -49,7 +49,9 @@ export const pinPlex = (card: { id: string; type: string }): PinTarget => ({
/** A unified discover card back to the thing it was pinned as — the inverse of the mapping in
* `app/discover/cards.py`. Null for a kind that cannot be pinned. */
export function pinTargetForCard(card: DiscoverCard): PinTarget | null {
export function pinTargetForCard(
card: Pick<DiscoverCard, "source" | "kind" | "id">,
): PinTarget | null {
if (card.source === "plex") return pinPlex({ id: card.id, type: card.kind });
if (card.kind === "video") return pinVideo(card.id);
if (card.kind === "channel") return pinChannel(card.id);
+6 -2
View File
@@ -23,8 +23,12 @@ export interface NavigationActions {
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
* after the call; with nothing open it is synchronous. The requested page being already current
* (with no channel page over it) pushes nothing — but the overlays still close. */
setPage: (next: Page) => void;
* (with no channel page over it) pushes nothing — but the overlays still close.
*
* `then` runs once the navigation has happened (the push made, or found unnecessary) — for a
* request that belongs to the NEW entry, which over an open player does not exist yet at the
* call (`lib/homeIntent`). */
setPage: (next: Page, then?: () => void) => void;
openChannel: (id: string, name?: string) => void;
closeChannel: () => void;
enterYtSearch: (q: string) => void;
+1 -1
View File
@@ -28,7 +28,7 @@ import { feedApi } from "./api/feed";
export type * from "./api/feed";
import { discoverApi } from "./api/discover";
export type * from "./api/discover";
export { DISCOVER_BUDGETS, discoverCardKey } from "./api/discover";
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
export type * from "./api/tagModes";
export {
DEFAULT_TAG_MODES,
+7
View File
@@ -15,6 +15,9 @@ export type DiscoverBudget = (typeof DISCOVER_BUDGETS)[number];
// Where the hero came from. Travels with the card so the page can label it honestly instead of
// inferring "where you left off" over something the user has never opened.
export type HeroTier = "resume" | "comfort" | "surprise";
/** The server's tier order (`app/discover/hero.py::HERO_TIERS`): a reroll's press count `start`
* leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */
export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"];
export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
@@ -43,6 +46,10 @@ export interface DiscoverCard {
plex: PlexCard | null;
// Shelf entries only.
pin_id?: number;
/** A comfort-tier hero only: the shelf entry it was resolved from — for a pinned SHOW, the show
* whose next episode this is. The hero's pin row acts on it rather than on the episode, which is
* not itself pinned (`app/discover/pins.py::playables`). */
pinned_from?: Pick<DiscoverCard, "source" | "kind" | "id" | "title">;
/** What actually plays when the tile is clicked: a show's next episode, a channel's newest
* unwatched video, or a copy of the tile itself when it plays directly. Null when the pin has
* nothing to offer right now (a fully-watched channel, an empty playlist). */
+19 -12
View File
@@ -1,3 +1,4 @@
import { ensureNavId, peekNavId } from "./history";
import { createStore } from "./store";
// A one-shot "…and surprise me straight away" request for the Home surface (E24), handed over by
@@ -5,18 +6,23 @@ import { createStore } from "./store";
//
// A TOKEN that waits, not an event that fires: Home is a lazy module and is normally NOT mounted
// when the request is made (the palette navigates to it in the same click), so an event would be
// shouted into an empty room. Home takes the token on arrival, which clears it, so walking back to
// Home an hour later does not replay the press — a stale reroll would look like the page deciding
// on its own that the answer you came back for was wrong.
const surprise = createStore(0);
// shouted into an empty room. Home takes the token on arrival, which clears it.
//
// The token is the HISTORY ENTRY it was requested for, and Home honours it only on that entry. A
// plain counter outlived a navigation that never mounted Home — a lazy chunk that failed, a Back
// before the module arrived — and replayed on the next visit, an hour later: the page deciding on
// its own that the answer you came back for was wrong. So it is minted AFTER the push
// (`setPage`'s `then`), on the entry the push made; Home mounting on any other entry drops it.
const surprise = createStore<number | null>(null);
/** Ask Home to reroll its hero as soon as it is on screen. Pair it with a `setPage("home")`. */
export const requestSurprise = (): void => surprise.set((n) => n + 1);
/** Ask Home to reroll its hero on THIS history entry. Call it once the navigation to Home has
* pushed — `setPage("home", requestSurprise)` — so the token names Home's own entry. */
export const requestSurprise = (): void => surprise.set(ensureNavId());
/** The pending token (0 = nothing pending) — the render subscription that wakes Home up. Home only. */
export const useSurpriseRequest = (): number => surprise.use();
/** The pending token (null = nothing pending) — the render subscription that wakes Home up. Home only. */
export const useSurpriseRequest = (): number | null => surprise.use();
/** Take the pending request, clearing it; false when there was nothing to take.
/** Take the pending request, clearing it; true only when it was requested for the entry Home is on.
*
* Compare-and-clear rather than a plain read + `consume()`, because a reroll is NOT idempotent — it
* spends a `/discover/pick` and steps the tier rotation — and React 18's StrictMode invokes a mount
@@ -24,7 +30,8 @@ export const useSurpriseRequest = (): number => surprise.use();
* "Meglepetés" from another module arrived at Home as `start=2`, two picks deep, before the page had
* painted once. Checking the LIVE store is what makes the second pass a no-op. */
export const takeSurprise = (): boolean => {
if (!surprise.get()) return false;
surprise.set(0);
return true;
const entry = surprise.get();
if (entry === null) return false;
surprise.set(null);
return entry === peekNavId();
};