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:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?.();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user