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
+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>