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
+20
View File
@@ -0,0 +1,20 @@
// Home's time budget in the address bar (`?page=home&hb=short`): a page SETTING, like a feed filter,
// so it is a link and survives a reload. What Home draws under it is not: the question's seed, the
// press and the cards already offered stay on the history entry (Home.tsx), because the same question
// draws other cards once what you have watched moves on — an address cannot promise "that view".
import { DISCOVER_BUDGETS, type DiscoverBudget } from "./api";
const HOME_BUDGET_KEY = "hb";
export const HOME_KEYS = [HOME_BUDGET_KEY] as const;
export const DEFAULT_BUDGET: DiscoverBudget = "any";
/** The budget a URL names, or null when it names none (or none Home offers). */
export function budgetFrom(params: URLSearchParams): DiscoverBudget | null {
const raw = params.get(HOME_BUDGET_KEY);
return DISCOVER_BUDGETS.includes(raw as DiscoverBudget) ? (raw as DiscoverBudget) : null;
}
/** The URL update for a budget: the default clears its key. */
export function budgetParams(budget: DiscoverBudget): Record<string, string | null> {
return { [HOME_BUDGET_KEY]: budget === DEFAULT_BUDGET ? null : budget };
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { ytCountFrom, ytSearchFrom, ytSearchParams } from "./ytSearchUrlState";
const p = (q: string) => new URLSearchParams(q);
describe("the live YouTube search's address", () => {
it("reads the term, trimmed; a blank one names no search", () => {
expect(ytSearchFrom(p("yt=%20lofi%20"))).toBe("lofi");
expect(ytSearchFrom(p("yt=%20%20"))).toBeNull();
expect(ytSearchFrom(p("q=lofi"))).toBeNull();
});
it("reads one of the offered counts, else the default", () => {
expect(ytCountFrom(p("ytn=60"))).toBe(60);
expect(ytCountFrom(p("ytn=61"))).toBe(20);
expect(ytCountFrom(p("ytn=x"))).toBe(20);
expect(ytCountFrom(p(""))).toBe(20);
});
it("writes the count only off its default, and nothing with no search", () => {
expect(ytSearchParams("lofi", 40)).toEqual({ yt: "lofi", ytn: "40" });
expect(ytSearchParams("lofi", 20)).toEqual({ yt: "lofi", ytn: null });
expect(ytSearchParams(null, 40)).toEqual({ yt: null, ytn: null });
});
it("round-trips", () => {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(ytSearchParams("lofi beats", 100))) if (v) q.set(k, v);
expect([ytSearchFrom(q), ytCountFrom(q)]).toEqual(["lofi beats", 100]);
});
});
+32
View File
@@ -0,0 +1,32 @@
// The feed's live YouTube search in the address bar (`?page=feed&yt=lofi&ytn=40`), so a search is a
// link and survives a reload. Only the term and how many results to gather: which source answers is
// the instance's setting at the moment the link opens (an admin may have switched it since), and that
// decides whether the search runs by itself (the free page scrape) or waits for a press (an API
// search spends 100 quota units, so nobody's reload or bookmark spends them unasked).
const YT_SEARCH_KEY = "yt";
const YT_COUNT_KEY = "ytn";
export const YT_SEARCH_KEYS = [YT_SEARCH_KEY, YT_COUNT_KEY] as const;
/** How many live-search results one fetch gathers; the first is the default, left out of the URL. */
export const YT_COUNTS = [20, 40, 60, 100] as const;
export const DEFAULT_YT_COUNT = YT_COUNTS[0];
/** The search a URL names, or null: a blank term names none (the search box never sends one). */
export function ytSearchFrom(params: URLSearchParams): string | null {
return params.get(YT_SEARCH_KEY)?.trim() || null;
}
/** The result count a URL names; anything but one of the offered counts is the default. */
export function ytCountFrom(params: URLSearchParams): number {
const n = Number(params.get(YT_COUNT_KEY));
return (YT_COUNTS as readonly number[]).includes(n) ? n : DEFAULT_YT_COUNT;
}
/** The URL updates for a search (null = none): a default count clears its key. */
export function ytSearchParams(term: string | null, count: number): Record<string, string | null> {
return {
[YT_SEARCH_KEY]: term,
[YT_COUNT_KEY]: term != null && count !== DEFAULT_YT_COUNT ? String(count) : null,
};
}