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
@@ -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;