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
+27 -21
View File
@@ -40,6 +40,8 @@ import {
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { useDebounced } from "../../lib/useDebounced";
import {
markedRows,
@@ -148,9 +150,6 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
return next;
};
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([
@@ -221,7 +220,8 @@ export default function Feed({
const qc = useQueryClient();
const videoMarks = useVideoMarks();
// Live search turned off on this instance: the empty feed offers no search that would be refused.
const searchOn = useMe().search_enabled;
const me = useMe();
const searchOn = me.search_enabled;
// What changed state while the player was open, and the writes still in flight: closing it
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
@@ -263,8 +263,16 @@ export default function Feed({
const ytActive = !!ytSearch;
// How many live-search results to gather (the count selector replaces a manual "load more";
// the free scrape source pages through continuations until this many are collected).
const [ytCount, setYtCount] = useState(20);
// the free scrape source pages through continuations until this many are collected). Named in the
// address with the term, so it is the navigation's, like the term.
const { ytCount, ytSearchAsked } = useNavigation();
const { setYtCount, askYtSearch } = useNavigationActions();
// A search runs by itself only where nobody's reload or link can spend quota with it: the free
// page scrape, or a search asked for in this document. An API search reopened by a reload, a link
// or Back waits for "Search YouTube again" — unless its results are still cached, which a disabled
// query shows all the same. Turned off on this instance it runs too: the server refuses it before
// any quota, and its refusal is what says so (no press offered that could only be refused).
const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn);
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
// ranking, which is what the view always showed before it had a control.
@@ -298,23 +306,11 @@ export default function Feed({
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive,
enabled: ytRuns,
staleTime: 5 * 60_000,
retry: false,
});
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
useEffect(() => {
if (!ytActive) return;
const st = window.history.state;
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
window.history.replaceState({ ...st, _ytScrape: true }, "");
}
}, [ytActive, ytSearch, ytQuery.data]);
// Switching to the relevance sort when a search starts happens atomically in the header's
// input onChange (race-free with the query update). Here we only handle the reverse: when
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
@@ -556,7 +552,8 @@ export default function Feed({
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
// Before an answer (a search waiting for its press), the instance's own source says it.
const zeroQuota = (ytQuery.data?.pages?.[0]?.source ?? me.search_source) === "scrape";
return (
<div className="p-4">
@@ -638,7 +635,16 @@ export default function Feed({
)}
</div>
{ytQuery.isLoading ? (
{!ytRuns && ytQuery.data === undefined ? (
// Reopened, not asked for: the term is on screen, the search waits for a press.
<div className="py-16 text-center max-w-md mx-auto">
<p className="font-semibold">{t("feed.yt.notRunTitle")}</p>
<p className="mt-1 text-sm text-muted">{t("feed.yt.notRunBody")}</p>
<Button className="mt-4" icon={<YouTubeIcon />} onClick={askYtSearch}>
{t("feed.yt.runAgain")}
</Button>
</div>
) : ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError && !ytItems.length ? (
// A first page that failed is said as the error it is, with Retry where trying again can