feat(nav): the live YouTube search and Home's budget are named in the address

A YouTube search is `?yt=<term>` (+ `ytn` for a non-default count), so a reload, a link
or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page
scrape, a search asked for in this document, or results still cached. An API search
reopened any other way shows its term and waits for "Search YouTube again".

Home's time budget is `hb`; the question's draw stays on the history entry.

An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and
docs/development.md name the module URL codec as the place for view state.
This commit is contained in:
2026-10-27 11:51:29 +01:00
parent 3d26ca0efa
commit 7b4e007680
20 changed files with 847 additions and 64 deletions
+34 -2
View File
@@ -174,8 +174,9 @@ function mountWithClient() {
}
beforeEach(() => {
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
window.history.replaceState(null, "");
// Home keeps its question on the history entry (Back restores it) and its budget in the address:
// each test starts a fresh one of both.
window.history.replaceState(null, "", "/");
discover.mockReset();
discoverPick.mockReset();
setPage.mockReset();
@@ -636,6 +637,37 @@ describe("Home", () => {
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked));
});
it("names the chosen budget in the address, and the default by leaving it out", async () => {
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.long"));
await waitFor(() => expect(window.location.search).toBe("?hb=long"));
fireEvent.click(screen.getByText("home.budget.any"));
await waitFor(() => expect(window.location.search).toBe(""));
});
it("opens on the budget a link names — the question's draw is not the link's", async () => {
window.history.replaceState(
{ sfHome: { budget: "short", seed: 7, press: 1, seen: ["youtube:v1"] } },
"",
"/?page=home&hb=long",
);
mount();
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("long", 0, "en"));
expect(discoverPick).not.toHaveBeenCalled(); // a question left under another budget is not reused
});
it("an address naming no budget opens on the default, over a question left under another", async () => {
window.history.replaceState(
{ sfHome: { budget: "short", seed: 7, press: 1, seen: [] } },
"",
"/?page=home",
);
mount();
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("any", 0, "en"));
expect(discoverPick).not.toHaveBeenCalled();
});
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
+15 -2
View File
@@ -79,6 +79,8 @@ import {
} from "../../components/ui/mediaCardStyles";
import RemoteImage from "../../components/ui/RemoteImage";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
import { useUrlMirror } from "../../lib/useUrlMirror";
// The Home surface — "Mit nézzek most?".
//
@@ -701,6 +703,15 @@ function readSavedQuestion(): SavedQuestion | null {
};
}
/** The question the page opens with: the budget its address names (a link wins, and an address
* naming none means the default), with the rest of what the entry was left with only when that was
* asked under the same budget. */
function openingQuestion(): { budget: DiscoverBudget; saved: SavedQuestion | null } {
const budget = budgetFrom(new URLSearchParams(window.location.search)) ?? DEFAULT_BUDGET;
const saved = readSavedQuestion();
return { budget, saved: saved?.budget === budget ? saved : null };
}
export default function Home() {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
@@ -710,8 +721,10 @@ export default function Home() {
// The question on screen lives on this page's history ENTRY as well: opening a card leaves Home
// (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card
// then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19).
const [saved] = useState(readSavedQuestion);
const [budget, setBudget] = useState<DiscoverBudget>(saved?.budget ?? "any");
// The budget alone is also in the address (lib/homeUrlState): a setting, unlike the draw under it.
const [{ budget: openedBudget, saved }] = useState(openingQuestion);
const [budget, setBudget] = useState<DiscoverBudget>(openedBudget);
useUrlMirror(budgetParams(budget));
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
// pressing the button does not refetch the rails under the user's cursor.
const [seed, setSeed] = useState(saved?.seed ?? 0);
+4 -3
View File
@@ -1,6 +1,7 @@
import { Home } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { HOME_KEYS } from "../../lib/homeUrlState";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const homeModule: ModuleDef = {
@@ -11,8 +12,8 @@ export const homeModule: ModuleDef = {
icon: Home,
Page: lazyWithPreload(() => import("./Home")),
maxWidth: "80rem",
// Nothing of its own: the question is an answer, not a setting — the same question draws other
// cards once what you have watched moves on.
urlKeys: [],
// Its time budget, a setting. Not the question's draw: the same question draws other cards once
// what you have watched moves on (lib/homeUrlState).
urlKeys: HOME_KEYS,
playsVideos: true,
};