Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 5163aefc675e52ff1bf2d7994414305726771d17
parent a2097c5df8fa99b153c9fcd4205f8c2fa276c166
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 20:44:58 -0400

hub: review fixes — an archive's posts/subs join only when it is ready; the answered line waits for every archive; focus rings; cancel an off archive's queued pages; "videos"

- An archive is ready only once its summaries AND its subs + posts manifests
  have settled; the subs, posts and alias merges are keyed on the ready set, so
  a failed archive contributes nothing and a loading one's posts no longer show
  before its videos.
- "N of M archives answered." shows only when nothing in scope is loading
  (so M = ready + failed); role="status"; "archive" for M = 1.
- focus-visible outline on the chip toggle, chip Retry and the line's Retry.
- The page queryFn consumes TanStack's signal: a page of an archive switched
  off mid-load bails at the gate instead of downloading.
- The live line says "videos", the chips' word for the same figure.
- Retry refetches only this provider's feeds of that origin.
- e2e: the answered line waits for a still-loading third archive (+1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/components/SearchDataContext.tsx | 99++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcommon/components/SearchResults.tsx | 17++++++++++++-----
Mexport/app/components/hub/HubScope.tsx | 4++--
Mexport/app/components/hub/HubStats.tsx | 7++++---
Mexport/e2e-hub/federated-search.spec.ts | 35+++++++++++++++++++++++++++++++----
5 files changed, 112 insertions(+), 50 deletions(-)

diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx @@ -223,6 +223,15 @@ export type FederatedSite = { // not share a connection pool, so a global cap would only slow the whole hub. const PAGE_FETCHES_PER_SITE = 6; +// The per-archive feeds this provider fetches — what a Retry refetches. +const FEDERATED_KEYS = new Set([ + "manifest", + "summaries-page", + "subs-manifest", + "posts-manifest", + "search-aliases", +]); + type Gate = { active: number; queue: Array<() => void> }; const pageGates = new Map<string, Gate>(); @@ -319,12 +328,46 @@ export function MultiSiteDataProvider({ const pageQueries = useQueries({ queries: pageDescriptors.map((d) => ({ queryKey: ["summaries-page", d.origin, d.index], - queryFn: () => - gated(d.origin, () => readerFor(d.origin).readSummariesPage(d.index)), + // Consuming `signal` lets TanStack cancel a page whose archive was + // switched off mid-load; a page still waiting at the gate then bails + // before it is fetched. + queryFn: ({ signal }: { signal: AbortSignal }) => + gated(d.origin, () => { + signal.throwIfAborted(); + return readerFor(d.origin).readSummariesPage(d.index); + }), })), }); - // 3. Each archive's state, from its manifest query and its page queries. + // Each archive's subs + posts manifests. Fetched alongside its summaries, + // merged below only once the archive is ready, so all of an archive's + // contributions land in the search in one re-run. A posts 404 (no posts + // corpus) resolves to an empty manifest, not a failure. + const subsQueries = useQueries({ + queries: sites.map((s) => ({ + queryKey: ["subs-manifest", s.origin], + queryFn: () => readerFor(s.origin).readSubsSiteManifest(), + enabled: inScope(s), + })), + }); + const postsQueries = useQueries({ + queries: sites.map((s) => ({ + queryKey: ["posts-manifest", s.origin], + queryFn: () => + readerFor(s.origin) + .readPostsSiteManifest() + .catch( + (): PostsManifest => ({ + version: 0, + channels: [], + totalCount: 0, + generatedAt: "", + }), + ), + enabled: inScope(s), + })), + }); + // 3. Each archive's state, from its manifest, page, subs and posts queries. const pagesByOrigin = new Map<string, (typeof pageQueries)[number][]>(); pageQueries.forEach((q, i) => { const origin = pageDescriptors[i]?.origin; @@ -344,10 +387,16 @@ export function MultiSiteDataProvider({ const pages = pagesByOrigin.get(s.origin) ?? []; const count = m?.data?.totalCount; const withCount = count === undefined ? base : { ...base, count }; + // A subs manifest that errored still counts as settled (the archive just + // has no live chat in the search); posts never error (404 → empty). + const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) => + !!q && (q.isSuccess || (q.isError && !q.isFetching)); if ( m?.isSuccess && pages.length === m.data.pageCount && - pages.every((p) => p.isSuccess) + pages.every((p) => p.isSuccess) && + settled(subsQueries[i]) && + settled(postsQueries[i]) ) { return { ...withCount, status: "ready" }; } @@ -457,22 +506,15 @@ export function MultiSiteDataProvider({ // 6. Merge subs manifests: concat channels (slug → origin-qualified) so the // chat scope resolves per-origin; sum the live-chat/total counts. Merged - // from whichever in-scope archives have answered so far. - const subsQueries = useQueries({ - queries: sites.map((s) => ({ - queryKey: ["subs-manifest", s.origin], - queryFn: () => readerFor(s.origin).readSubsSiteManifest(), - enabled: inScope(s), - })), - }); + // over the READY archives only (a failed archive contributes nothing). const subsKey = sites - .map((s, i) => (inScope(s) && subsQueries[i]?.data ? s.origin : "")) + .map((s, i) => (readyOrigins.has(s.origin) && subsQueries[i]?.data ? s.origin : "")) .join("\u0000"); const subsManifest = useMemo<SubsManifest | null>(() => { const loaded = sites .map((s, i) => ({ origin: s.origin, - data: inScope(s) ? subsQueries[i]?.data : undefined, + data: readyOrigins.has(s.origin) ? subsQueries[i]?.data : undefined, })) .filter((e): e is { origin: string; data: SubsManifest } => !!e.data); if (loaded.length === 0) return null; @@ -502,31 +544,14 @@ export function MultiSiteDataProvider({ // with no posts corpus 404s; treat that as an empty contribution so one // video-only origin can't blank the hub's posts scope (and it is not a // failure of that archive). - const postsQueries = useQueries({ - queries: sites.map((s) => ({ - queryKey: ["posts-manifest", s.origin], - queryFn: () => - readerFor(s.origin) - .readPostsSiteManifest() - .catch( - (): PostsManifest => ({ - version: 0, - channels: [], - totalCount: 0, - generatedAt: "", - }), - ), - enabled: inScope(s), - })), - }); const postsKey = sites - .map((s, i) => (inScope(s) && postsQueries[i]?.data ? s.origin : "")) + .map((s, i) => (readyOrigins.has(s.origin) && postsQueries[i]?.data ? s.origin : "")) .join("\u0000"); const postsManifest = useMemo<PostsManifest | null>(() => { const loaded = sites .map((s, i) => ({ origin: s.origin, - data: inScope(s) ? postsQueries[i]?.data : undefined, + data: readyOrigins.has(s.origin) ? postsQueries[i]?.data : undefined, })) .filter((e): e is { origin: string; data: PostsManifest } => !!e.data); if (loaded.length === 0) return null; @@ -560,12 +585,12 @@ export function MultiSiteDataProvider({ })), }); const aliasesKey = sites - .map((s, i) => (inScope(s) && aliasQueries[i]?.data ? s.origin : "")) + .map((s, i) => (readyOrigins.has(s.origin) && aliasQueries[i]?.data ? s.origin : "")) .join("\u0000"); const aliases = useMemo<SearchAlias[]>(() => { let merged: SearchAlias[] = []; sites.forEach((s, i) => { - const data = inScope(s) ? aliasQueries[i]?.data : undefined; + const data = readyOrigins.has(s.origin) ? aliasQueries[i]?.data : undefined; if (data) merged = mergeAliases(merged, data); }); return merged; @@ -665,7 +690,9 @@ export function MultiSiteDataProvider({ (origin: string) => { void queryClient.refetchQueries({ predicate: (q) => - q.queryKey[1] === origin && q.state.status === "error", + FEDERATED_KEYS.has(String(q.queryKey[0])) && + q.queryKey[1] === origin && + q.state.status === "error", }); }, [queryClient], diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -324,14 +324,19 @@ export default function SearchResults() { // above the results says how many did, names the ones that did not, and offers // a Retry for them. The results from the archives that answered render as // usual underneath — one member failing never empties the page. Nothing in -// single-site mode (no `federation`), and nothing while every archive is fine -// or still loading (the scope chips show loading). +// single-site mode (no `federation`), nothing while every archive is fine, and +// nothing while any archive in scope is still loading (the chips show that), so +// "N of M" always names every archive it did not count. function FederationStatus() { const { federation } = useSearchData(); if (!federation) return null; const scoped = federation.sites.filter((s) => s.status !== "off"); const failed = scoped.filter((s) => s.status === "failed"); - if (failed.length === 0) return null; + // Only once every archive in scope has settled, so the count is final and + // every archive not counted is named (the chips show what is still loading). + if (failed.length === 0 || scoped.some((s) => s.status === "loading")) { + return null; + } const answered = scoped.filter((s) => s.status === "ready").length; const names = failed.map((s) => s.siteTitle); const list = @@ -340,11 +345,13 @@ function FederationStatus() { : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; return ( <p + role="status" data-testid="hub-scope-status" className="mb-2 flex flex-wrap items-baseline gap-x-2 text-sm text-muted-foreground" > <span> - {answered} of {scoped.length} archives answered. {list}{" "} + {answered} of {scoped.length}{" "} + {scoped.length === 1 ? "archive" : "archives"} answered. {list}{" "} did not, so{" "} {names.length === 1 ? "its" : "their"} videos are not in these results. </span> @@ -353,7 +360,7 @@ function FederationStatus() { onClick={() => { for (const s of failed) federation.retry(s.origin); }} - className="text-brand transition-colors hover:underline" + className="text-brand transition-colors hover:underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > Retry </button> diff --git a/export/app/components/hub/HubScope.tsx b/export/app/components/hub/HubScope.tsx @@ -64,7 +64,7 @@ export default function HubScope({ } onClick={() => onToggle(s.origin)} className={cn( - "flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 select-none hover:bg-accent hover:text-accent-foreground", + "flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 select-none hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring", on ? "text-foreground" : "text-muted-foreground", )} > @@ -91,7 +91,7 @@ export default function HubScope({ onClick={() => retry(s.origin)} aria-label={`Retry ${s.siteTitle}`} title={s.error ? `${s.error} — try again` : "Try again"} - className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent" + className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > Retry </button> diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx @@ -23,10 +23,11 @@ export default function HubStats() { const { sites } = useRegistry(); const { summariesState, channels, federation } = useSearchData(); // The archives in scope (the visitor's chips), and what has arrived from them: - // channels as each manifest lands, transcripts as each archive is ready. + // channels as each manifest lands, videos as each archive is ready. const archives = federation?.sites.filter((s) => s.status !== "off").length ?? sites.length; - const transcripts = summariesState.manifest?.totalCount ?? 0; + // The ready archives' record counts — the same figures the chips show. + const videos = summariesState.manifest?.totalCount ?? 0; if (sites.length === 0) return null; @@ -36,7 +37,7 @@ export default function HubStats() { <span className="mx-2 text-muted-foreground/40">·</span> {part(channels.length, "channel", "channels")} <span className="mx-2 text-muted-foreground/40">·</span> - {part(transcripts, "transcript", "transcripts")} right now. + {part(videos, "video", "videos")} right now. </p> ); } diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts @@ -24,6 +24,7 @@ async function fulfillJson(route: Route, body: unknown) { type Member = { origin: string; title: string; channel: string; video: string }; const A: Member = { origin: ORIGIN_A, title: "Origin A", channel: "Channel A", video: "Alpha Video" }; const B: Member = { origin: ORIGIN_B, title: "Origin B", channel: "Channel B", video: "Bravo Video" }; +const C: Member = { origin: "http://localhost:4597", title: "Origin C", channel: "Channel C", video: "Charlie Video" }; function manifestOf(m: Member) { return { @@ -39,7 +40,7 @@ function manifestOf(m: Member) { } function pageOf(m: Member) { - const id = m === A ? "vida1" : "vidb1"; + const id = m === A ? "vida1" : m === B ? "vidb1" : "vidc1"; return [ { slug: `chan/${id}`, @@ -101,11 +102,11 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> { return mock; } -async function setup(page: Page) { +async function setup(page: Page, members: Member[] = [A, B]) { await page.route("**/hub-sites.json", (r) => fulfillJson( r, - [A, B].map((m, i) => ({ + members.map((m, i) => ({ siteId: `origin${i}`, siteTitle: m.title, siteUrl: m.origin, @@ -117,7 +118,10 @@ async function setup(page: Page) { await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" }), ); - return { a: await mockMember(page, A), b: await mockMember(page, B) }; + const a = await mockMember(page, A); + const b = await mockMember(page, B); + const c = members.includes(C) ? await mockMember(page, C) : null; + return { a, b, c }; } const chip = (page: Page, m: Member) => @@ -198,6 +202,7 @@ test.describe("hub federated search — scope, per-archive state, attribution", chip(page, B).getByRole("button", { name: "Retry Origin B" }), ).toBeVisible(); await expect(chip(page, A)).toHaveAttribute("data-status", "ready"); + await expect(status(page)).toHaveAttribute("role", "status"); await expect(status(page)).toContainText("1 of 2 archives answered."); await expect(status(page)).toContainText("Origin B did not"); // The healthy member's results still render. @@ -245,4 +250,26 @@ test.describe("hub federated search — scope, per-archive state, attribution", await expect(resultFrom(page, B)).toHaveCount(1); await expect(resultFrom(page, A)).toHaveCount(1); }); + + test("the answered line waits until every archive in scope has settled", async ({ + page, + }) => { + const mocks = await setup(page, [A, B, C]); + mocks.b.pages = "abort"; + mocks.c!.pages = "hold"; + await page.goto("/"); + + await expect(chip(page, B)).toHaveAttribute("data-status", "failed"); + await expect(chip(page, C)).toHaveAttribute("data-status", "loading"); + // Origin C is neither answered nor failed yet: no count that leaves it out. + await expect(status(page)).toHaveCount(0); + await expect(resultFrom(page, A)).toHaveCount(1); + + await expect.poll(() => mocks.c!.held.length).toBeGreaterThan(0); + for (const r of mocks.c!.held.splice(0)) await fulfillJson(r, pageOf(C)); + await expect(chip(page, C)).toHaveAttribute("data-status", "ready"); + await expect(status(page)).toContainText("2 of 3 archives answered."); + await expect(status(page)).toContainText("Origin B did not"); + await expect(resultFrom(page, C)).toHaveCount(1); + }); });