"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import type { DisplaySummary } from "../../lib/transcripts"; import type { VideoStat } from "../../lib/stats"; import type { ChartConfig, SearchSource } from "../../lib/chartConfig"; import { applyFilters, type ChartData } from "../../lib/chartAggregate"; import { seriesFromSlugs } from "../../lib/chartSeriesFromSlugs"; import { parseRoot } from "../../lib/searchQuery"; import { runQueryTree, type TreeProgress } from "../../lib/searchEval"; import { searchRuntime } from "../searchPipeline"; export type SearchSeriesState = { data: ChartData; loading: boolean; matchedVideos: number; totalHits: number; error: string | null; }; const EMPTY: ChartData = { categories: [], series: [] }; // Runs the viewer search engine for a chart's `search` block and bins the // matched videos by the chart's x-axis. Reuses the exact binning/grouping // helpers as metadata charts so the two look identical. export function useSearchSeries( config: ChartConfig, search: SearchSource, stats: readonly VideoStat[], summaries: DisplaySummary[], enabled: boolean, ): SearchSeriesState { const [progress, setProgress] = useState(null); const [error, setError] = useState(null); const controllerRef = useRef<{ cancel(): void } | null>(null); const root = useMemo(() => parseRoot(search.queryTree), [search.queryTree]); // Narrow the search to videos passing the chart's filters (date range, // channel, platform, …) instead of the whole corpus. This is what makes a // date limit actually reduce load — the engine only fetches transcripts for // in-scope videos. A stable key avoids re-running on unrelated renders. const scopeSlugs = useMemo(() => { const summarySet = new Set(summaries.map((s) => s.slug)); return applyFilters(stats, config.filters) .map((s) => s.slug) .filter((slug) => summarySet.has(slug)); }, [stats, summaries, config.filters]); const scopeKey = scopeSlugs.length + ":" + (scopeSlugs[0] ?? "") + (scopeSlugs[scopeSlugs.length - 1] ?? ""); useEffect(() => { setProgress(null); setError(null); controllerRef.current?.cancel(); controllerRef.current = null; if (!enabled || !root || scopeSlugs.length === 0) return; try { const controller = runQueryTree({ root, runtime: searchRuntime, globalScope: scopeSlugs, summaries, initialHitLimit: 50000, concurrency: 8, flushIntervalMs: 150, emit: (p) => setProgress(p), }); controllerRef.current = controller; } catch (e) { setError(e instanceof Error ? e.message : String(e)); } return () => { controllerRef.current?.cancel(); controllerRef.current = null; }; // scopeKey captures filter/scope changes without churning on identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, root, summaries, scopeKey]); const statBySlug = useMemo(() => { const m = new Map(); for (const s of stats) m.set(s.slug, s); return m; }, [stats]); const result = useMemo(() => { if (!enabled || !root) { return { data: EMPTY, loading: false, matchedVideos: 0, totalHits: 0, error }; } if (!progress) { return { data: EMPTY, loading: true, matchedVideos: 0, totalHits: 0, error }; } // Narrow matched slugs to those passing the chart filters and present in // the stats dataset, tallying the headline counts as we go. The dashboard // path runs against the whole corpus, so (unlike the search page) it still // needs this per-stat filter pass. const eligible: string[] = []; let matchedVideos = 0; let totalHits = 0; for (const slug of progress.slugs) { const stat = statBySlug.get(slug); if (!stat) continue; if (applyFilters([stat], config.filters).length === 0) continue; matchedVideos += 1; totalHits += progress.hits.get(slug)?.length ?? 0; eligible.push(slug); } const data = seriesFromSlugs(eligible, statBySlug, config, (slug) => search.metric === "totalHits" ? progress.hits.get(slug)?.length ?? 0 : 1, ); return { data, loading: !progress.done, matchedVideos, totalHits, error, }; }, [enabled, root, progress, statBySlug, config, search.metric, error]); return result; }