Archilyzer · Source

archilyzer

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

commit 0ddd9434e22300fb54d9edda93105e19ce9f7ee5
parent e7862a94777ed795f5360002972845d49cca2872
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 14 Jun 2026 04:19:01 -0400

Merge branch 'worktree-charts-search-ui'

Charts as a Search view mode, Description/Tags search scopes, and new chart
dimensions (Media type/Shorts, Status, Tag).

Diffstat:
Mcommon/components/QueryLeafView.tsx | 24+++++++++++++++++-------
Mcommon/components/TranscriptSearch.tsx | 341+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcommon/components/charts/ChartConfigEditor.tsx | 10+++++++++-
Acommon/components/charts/ChartShapeControls.tsx | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/SearchChartPanel.tsx | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/charts/useSearchSeries.ts | 78++++++++++++------------------------------------------------------------------
Mcommon/components/searchPipeline.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcommon/controller/buildIndex.ts | 4+++-
Mcommon/controller/buildStats.ts | 0
Mcommon/lib/chartAggregate.ts | 41+++++++++++++++++++++++++++++++++++------
Mcommon/lib/chartConfig.ts | 24++++++++++++++++++++++--
Acommon/lib/chartSeriesFromSlugs.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/chartShare.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/searchQuery.ts | 15+++++++++++++--
Mcommon/lib/stats.ts | 13++++++++++++-
Mcommon/lib/transcripts-server.ts | 30++++++++++++++++++++++++++++--
Mcommon/lib/transcripts.ts | 3+++
Mexport/CHANGELOG.md | 3+++
Dexport/app/charts/ChartsClient.tsx | 66------------------------------------------------------------------
Dexport/app/charts/page.tsx | 19-------------------
Mexport/app/layout.tsx | 6------
Mexport/e2e/charts.spec.ts | 301+++++++++++++++++++++++++++++++++----------------------------------------------
Mexport/e2e/fixtures/data.ts | 25++++++++++++++++++++-----
Mexport/e2e/query-tree.spec.ts | 39++++++++++++++++++++++++++++++++++++++-
Mexport/e2e/search-date-filter.spec.ts | 31+++++++++++++++++++------------
Mexport/e2e/share-current-search.spec.ts | 3+++
26 files changed, 1184 insertions(+), 558 deletions(-)

diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx @@ -28,6 +28,8 @@ const SCOPE_LABELS: Record<LayerScope, string> = { transcripts: "Transcripts", chat: "Live chat", metadata: "Title / channel", + description: "Description", + tags: "Tags", }; export default function QueryLeafView({ @@ -51,13 +53,21 @@ export default function QueryLeafView({ ? leaf.useRegex ? "Title/channel regex..." : "Match against title or channel..." - : leaf.scope === "chat" + : leaf.scope === "description" ? leaf.useRegex - ? "Live chat regex..." - : "Search live chat..." - : leaf.useRegex - ? "Regex pattern (case-insensitive)..." - : "Search transcripts..."; + ? "Description regex..." + : "Search video descriptions..." + : leaf.scope === "tags" + ? leaf.useRegex + ? "Tags regex..." + : "Search video tags..." + : leaf.scope === "chat" + ? leaf.useRegex + ? "Live chat regex..." + : "Search live chat..." + : leaf.useRegex + ? "Regex pattern (case-insensitive)..." + : "Search transcripts..."; const queryInput = ( <input @@ -84,7 +94,7 @@ export default function QueryLeafView({ data-testid={`leaf-scope-${leaf.id}`} className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs" > - {(["transcripts", "chat", "metadata"] as const).map((s) => ( + {(["transcripts", "chat", "metadata", "description", "tags"] as const).map((s) => ( <option key={s} value={s}> {SCOPE_LABELS[s]} </option> diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -23,6 +23,7 @@ import { rootFromLegacy, stringifyRoot, type GroupNode, + type LayerScope, } from "../lib/searchQuery"; import { useUrlParams, writeUrlParams } from "./urlState"; import { @@ -49,11 +50,13 @@ import { import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; import QueryBuilder from "./QueryBuilder"; import { - chartFromSearch, - CHART_THIS_SEARCH_TEMPLATES, - type ChartFilters, -} from "../lib/chartConfig"; -import { stringifyChart } from "../lib/chartShare"; + defaultChartShape, + parseChartShape, + stringifyChartShape, + type ChartShape, +} from "../lib/chartShare"; +import { ChartShapeControls } from "./charts/ChartShapeControls"; +import { SearchChartPanel } from "./charts/SearchChartPanel"; import { LayerSwatch } from "./LayerSwatch"; import { ymdToInput, inputToYmd } from "../lib/ymd"; import type { DisplaySummary } from "../lib/transcripts"; @@ -152,7 +155,7 @@ function buildSnapshotDeltas( function anyLeafHasScope( root: GroupNode, - scope: "transcripts" | "chat" | "metadata", + scope: LayerScope, ): boolean { let found = false; forEachLeaf(root, (leaf) => { @@ -226,6 +229,12 @@ export default function TranscriptSearch() { ); const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(false); + // Results vs. chart view of the current search. `chartShape` is created + // lazily (never during the server pass) to keep deterministic-id hydration + // quiet — the same reason the QueryBuilder is gated behind `mounted`. + const [view, setView] = useState<"results" | "chart">("results"); + const [chartShape, setChartShape] = useState<ChartShape | null>(null); + // Tracks whether the user has committed at least once this session. Used // by the placeholder copy below — until the user has searched, we show a // "tip" hint rather than the "no matching videos" empty state. @@ -870,10 +879,30 @@ export default function TranscriptSearch() { setCommittedNu(initialNu); setCommittedDateFrom(initialDateFrom); setCommittedDateTo(initialDateTo); + + // Chart view + shape (after query/filters so the default data source can + // key off whether a query was restored). + const csRaw = params.get("cs"); + const wantsChart = params.get("view") === "chart"; + if (csRaw) { + setChartShape(parseChartShape(csRaw)); + } else if (wantsChart) { + const active = resolvedRoot ? isNodeActive(resolvedRoot) : false; + setChartShape(defaultChartShape(active ? "search" : "metadata")); + } + if (wantsChart) setView("chart"); + setHydrated(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelOptions.length === 0, hydrated]); + // Reflect the chart view + shape in the URL live (post-hydration only), so the + // address bar always carries the current chart — mirrors how qt= is written. + useEffect(() => { + if (!hydrated) return; + writeViewChartParams(view, view === "chart" ? chartShape : null); + }, [hydrated, view, chartShape]); + // Persist UI-only collapse state immediately on toggle. Loads + merges + // saves so concurrent writes to other top-level fields aren't clobbered. const persistUiCollapse = useCallback( @@ -1198,6 +1227,12 @@ export default function TranscriptSearch() { if (isNodeActive(committedRoot)) { params.set("qt", stringifyRoot(committedRoot)); } + // Carry the chart view + its shape so a shared link opens straight to the + // same chart (query/filters stay in qt= and the share params above). + if (view === "chart") { + params.set("view", "chart"); + if (chartShape) params.set("cs", stringifyChartShape(chartShape)); + } const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; try { await navigator.clipboard.writeText(url); @@ -1224,60 +1259,16 @@ export default function TranscriptSearch() { committedDateFrom, committedDateTo, committedRoot, + view, + chartShape, ]); - // name → slug, so "Chart this search" can translate the search's channel - // selection (held by name) into the chart's slug-based channel filter. The - // summaries manifest only carries names, so derive the mapping from the - // loaded summaries (which carry both). - const channelSlugByName = useMemo(() => { - const m = new Map<string, string>(); - if (summaries) - for (const s of summaries) - if (s.channel && !m.has(s.channel)) m.set(s.channel, s.channelSlug); - return m; - }, [summaries]); - - const handleChartThisSearch = useCallback( - (templateId: string, mode: "add" | "replace") => { - if (!isNodeActive(committedRoot)) return; - // Carry the channel selection: when some channels are excluded, pass the - // selected ones (mapped to slugs) as the chart's channel filter. - const selectedSlugs: string[] = []; - let excludedAny = false; - for (const name of channelOptions) { - if (committedExcludedChannels.has(name)) { - excludedAny = true; - continue; - } - const slug = channelSlugByName.get(name); - if (slug) selectedSlugs.push(slug); - } - const filters: ChartFilters = {}; - if (excludedAny && selectedSlugs.length) filters.channels = selectedSlugs; - // Reuse the search's date range so the chart opens scoped to the same - // window. ChartFilters uses the same inclusive "YYYYMMDD" bounds. - if (committedDateFrom) filters.dateFrom = committedDateFrom; - if (committedDateTo) filters.dateTo = committedDateTo; - const config = chartFromSearch( - templateId, - stringifyRoot(committedRoot), - filters, - ); - const enc = encodeURIComponent(stringifyChart(config)); - window.location.assign( - `/charts?addChart=${enc}${mode === "replace" ? "&chartMode=replace" : ""}`, - ); - }, - [ - committedRoot, - channelOptions, - committedExcludedChannels, - channelSlugByName, - committedDateFrom, - committedDateTo, - ], - ); + // Switch to the chart view, creating the chart shape on first use. Defaults + // its data source to the current search (or metadata when there's no query). + const showChartView = useCallback(() => { + setChartShape((s) => s ?? defaultChartShape(hasActiveQuery ? "search" : "metadata")); + setView("chart"); + }, [hasActiveQuery]); const setSelected = useCallback( (targets: ReadonlyArray<string>, selected: boolean) => { @@ -1307,7 +1298,7 @@ export default function TranscriptSearch() { { query: string; useRegex: boolean; - scope: "transcripts" | "chat" | "metadata"; + scope: LayerScope; } >(); forEachLeaf(committedRoot, (leaf) => @@ -1331,6 +1322,15 @@ export default function TranscriptSearch() { return ids; }, [committedRoot]); + // The chart plots the live search results when there's a query, otherwise the + // in-scope (filter-passing) videos. `effectiveShape` guards the null shape on + // the server pass / before lazy init. + const effectiveShape = + chartShape ?? defaultChartShape(hasActiveQuery ? "search" : "metadata"); + const chartSlugs: Iterable<string> = hasActiveQuery + ? treeProgress?.slugs ?? [] + : globalScopeSlugs; + return ( <div className="flex flex-col gap-6"> <ProfilesRow @@ -1345,8 +1345,6 @@ export default function TranscriptSearch() { onDelete={handleDeleteProfile} onRevert={handleRevertProfile} onShareCurrentSearch={handleShareCurrentSearch} - onChartThisSearch={handleChartThisSearch} - chartDisabled={!hasActiveQuery} /> <form onSubmit={(e) => { @@ -1758,44 +1756,98 @@ export default function TranscriptSearch() { {pipelineActive ? "…" : ""} </span> )} - </h2> - {!hasActiveQuery && !summariesLoading && resultGroups.length > 0 && ( - <p className="text-sm text-zinc-500 mb-2" data-testid="browse-hint"> - Showing all videos — type a query above to narrow. - </p> - )} - {hasActiveQuery - ? !indexLoading && - resultGroups.length === 0 && - !pipelineActive && ( - <p className="text-sm text-zinc-500">No matching videos.</p> - ) - : !summariesLoading && - resultGroups.length === 0 && ( - <p className="text-sm text-zinc-500"> - No videos match the current filters. - </p> - )} - {resultGroups.length > 0 && ( - <VirtualResultList - resultGroups={resultGroups} - leavesById={leavesById} - orderedLeafIds={orderedLeafIds} - activeVideo={activeVideo} - activeTime={activeTime} - openWithMode={openWithMode} - /> - )} - {capped && ( - <div className="mt-3 flex justify-center"> + <span + data-testid="view-toggle" + className="ml-auto inline-flex overflow-hidden rounded-md border border-zinc-300 dark:border-zinc-700" + > <button type="button" - onClick={() => setHitLimit((l) => l + hitBatchSize)} - className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" + onClick={() => setView("results")} + className={`px-2.5 py-1 text-xs font-normal ${ + view === "results" + ? "bg-blue-600 text-white" + : "bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800" + }`} > - Load more results + Results </button> + <button + type="button" + onClick={showChartView} + className={`px-2.5 py-1 text-xs font-normal ${ + view === "chart" + ? "bg-blue-600 text-white" + : "bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800" + }`} + > + Chart + </button> + </span> + </h2> + + {view === "chart" ? ( + <div className="flex flex-col gap-4"> + <details + data-testid="chart-options" + open + className="rounded-lg border border-zinc-200 dark:border-zinc-800 px-3 py-2" + > + <summary className="cursor-pointer text-sm font-medium text-zinc-600 dark:text-zinc-300"> + Chart options + </summary> + <div className="pt-3"> + <ChartShapeControls shape={effectiveShape} onChange={setChartShape} /> + </div> + </details> + <SearchChartPanel + slugs={chartSlugs} + hitsBySlug={treeProgress?.hits ?? null} + scopeSlugs={globalScopeSlugs} + shape={effectiveShape} + loading={pipelineActive} + /> </div> + ) : ( + <> + {!hasActiveQuery && !summariesLoading && resultGroups.length > 0 && ( + <p className="text-sm text-zinc-500 mb-2" data-testid="browse-hint"> + Showing all videos — type a query above to narrow. + </p> + )} + {hasActiveQuery + ? !indexLoading && + resultGroups.length === 0 && + !pipelineActive && ( + <p className="text-sm text-zinc-500">No matching videos.</p> + ) + : !summariesLoading && + resultGroups.length === 0 && ( + <p className="text-sm text-zinc-500"> + No videos match the current filters. + </p> + )} + {resultGroups.length > 0 && ( + <VirtualResultList + resultGroups={resultGroups} + leavesById={leavesById} + orderedLeafIds={orderedLeafIds} + activeVideo={activeVideo} + activeTime={activeTime} + openWithMode={openWithMode} + /> + )} + {capped && ( + <div className="mt-3 flex justify-center"> + <button + type="button" + onClick={() => setHitLimit((l) => l + hitBatchSize)} + className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" + > + Load more results + </button> + </div> + )} + </> )} </section> </div> @@ -1807,7 +1859,7 @@ export default function TranscriptSearch() { type LeafInfo = { query: string; useRegex: boolean; - scope: "transcripts" | "chat" | "metadata"; + scope: LayerScope; }; // Each card is one virtualized element. Intra-card layout (header, leaf @@ -2059,8 +2111,6 @@ function ProfilesRow({ onDelete, onRevert, onShareCurrentSearch, - onChartThisSearch, - chartDisabled, }: { profileNames: string[]; activeProfileName: string | null; @@ -2073,8 +2123,6 @@ function ProfilesRow({ onDelete: () => void; onRevert: () => void; onShareCurrentSearch: () => void; - onChartThisSearch: (templateId: string, mode: "add" | "replace") => void; - chartDisabled: boolean; }) { const showRevert = activeProfileName != null && profileDirty; return ( @@ -2150,10 +2198,6 @@ function ProfilesRow({ Delete </button> <div className="ml-auto flex items-center gap-x-3"> - <ChartThisSearchMenu - onChart={onChartThisSearch} - disabled={chartDisabled} - /> <button type="button" onClick={onShareCurrentSearch} @@ -2171,77 +2215,6 @@ function ProfilesRow({ ); } -// Dropdown beside "Share current search": pick a starting chart template and -// whether to add it to the existing board or open it as a single-chart board, -// then jump to /charts with the current query pre-filled. -function ChartThisSearchMenu({ - onChart, - disabled, -}: { - onChart: (templateId: string, mode: "add" | "replace") => void; - disabled: boolean; -}) { - const [open, setOpen] = useState(false); - const [addToBoard, setAddToBoard] = useState(true); - const ref = useRef<HTMLDivElement | null>(null); - - useEffect(() => { - if (!open) return; - const onDoc = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) - setOpen(false); - }; - document.addEventListener("mousedown", onDoc); - return () => document.removeEventListener("mousedown", onDoc); - }, [open]); - - return ( - <div className="relative" ref={ref}> - <button - type="button" - disabled={disabled} - onClick={() => setOpen((v) => !v)} - title={ - disabled - ? "Enter a search to chart it" - : "Build a chart from this search" - } - className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-40 disabled:no-underline" - > - Chart this search ▾ - </button> - {open && !disabled && ( - <div - data-testid="chart-this-search-menu" - className="absolute right-0 z-10 mt-1 flex w-60 flex-col rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1 shadow-lg" - > - {CHART_THIS_SEARCH_TEMPLATES.map((t) => ( - <button - key={t.id} - type="button" - onClick={() => { - setOpen(false); - onChart(t.id, addToBoard ? "add" : "replace"); - }} - className="rounded px-2 py-1.5 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800" - > - {t.label} - </button> - ))} - <label className="mt-1 flex items-center gap-2 border-t border-zinc-200 dark:border-zinc-800 px-2 py-1.5 text-xs text-zinc-500"> - <input - type="checkbox" - checked={addToBoard} - onChange={(e) => setAddToBoard(e.target.checked)} - /> - Add to existing board - </label> - </div> - )} - </div> - ); -} - function ChannelAllToggle({ channelOptions, draftExcludedChannels, @@ -2456,3 +2429,23 @@ function writeQtParam(value: string): void { window.history.replaceState(window.history.state, "", next); window.dispatchEvent(new PopStateEvent("popstate")); } + +// Mirror writeQtParam for the chart view/shape params. Keeps the address bar in +// sync with the current chart so refresh/Back restore it (same dedicated-mutator +// pattern as qt=). +function writeViewChartParams( + view: "results" | "chart", + shape: ChartShape | null, +): void { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + if (view === "chart") params.set("view", "chart"); + else params.delete("view"); + if (view === "chart" && shape) params.set("cs", stringifyChartShape(shape)); + else params.delete("cs"); + const qs = params.toString(); + const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`; + if (next === window.location.pathname + window.location.search) return; + window.history.replaceState(window.history.state, "", next); + window.dispatchEvent(new PopStateEvent("popstate")); +} diff --git a/common/components/charts/ChartConfigEditor.tsx b/common/components/charts/ChartConfigEditor.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { + CategoryField, ChartConfig, ChartType, MetricField, @@ -111,6 +112,8 @@ export function ChartConfigEditor({ <option value="channel">Channel</option> <option value="platform">Platform</option> <option value="language">Language</option> + <option value="mediaType">Media type</option> + <option value="status">Status</option> </select> </label> @@ -189,7 +192,7 @@ export function ChartConfigEditor({ onChange={(e) => patchX({ kind: "category", - field: e.target.value as "channel" | "platform" | "language" | "category", + field: e.target.value as CategoryField, }) } > @@ -197,6 +200,11 @@ export function ChartConfigEditor({ <option value="platform">Platform</option> <option value="language">Language</option> <option value="category">Category</option> + <option value="type">Type (video/livestream)</option> + <option value="mediaType">Media type (incl. shorts)</option> + <option value="status">Status (available/deleted)</option> + <option value="tag">Tag</option> + <option value="hasTranscript">Has transcript</option> </select> </label> )} diff --git a/common/components/charts/ChartShapeControls.tsx b/common/components/charts/ChartShapeControls.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useState } from "react"; +import { + PRESET_GROUPS, + type CategoryField, + type ChartConfig, + type ChartType, + type MetricField, + type SeriesGroupBy, + type TimeBin, + type XAxisField, + type YMetric, +} from "../../lib/chartConfig"; +import type { ChartShape } from "../../lib/chartShare"; + +const sel = + "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"; +const lbl = "flex flex-col gap-1 text-xs font-medium text-zinc-500"; + +const METRIC_FIELDS: { value: MetricField; label: string }[] = [ + { value: "duration", label: "Duration" }, + { value: "viewCount", label: "Views" }, + { value: "likeCount", label: "Likes" }, + { value: "commentCount", label: "Comments" }, + { value: "channelFollowerCount", label: "Followers" }, + { value: "cueCount", label: "Cue count" }, +]; + +// Map a gallery template's ChartConfig onto the search-page chart shape. The +// query inside the template is ignored — the chart plots the live search. +function shapeFromConfig(c: ChartConfig, prev: ChartShape): ChartShape { + return { + dataSource: c.search ? "search" : "metadata", + type: c.type, + x: c.x, + y: c.y, + groupBy: c.groupBy, + cumulative: c.cumulative, + metric: c.search?.metric ?? prev.metric, + }; +} + +// Controlled chart-configuration panel for the search page's single chart. +// Mirrors ChartConfigEditor's controls (same labels/markup) but edits a +// ChartShape live (no Save/Cancel) and carries no embedded query builder — +// the query IS the current search. +export function ChartShapeControls({ + shape, + onChange, +}: { + shape: ChartShape; + onChange: (next: ChartShape) => void; +}) { + const [showExamples, setShowExamples] = useState(false); + const patch = (p: Partial<ChartShape>) => onChange({ ...shape, ...p }); + const patchX = (x: XAxisField) => patch({ x }); + const patchY = (y: YMetric) => patch({ y }); + + return ( + <div className="flex flex-col gap-4"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div className="flex flex-col gap-1"> + <span className={lbl}>Data source</span> + <div className="inline-flex w-fit overflow-hidden rounded-md border border-zinc-300 dark:border-zinc-700"> + {(["search", "metadata"] as const).map((m) => ( + <button + key={m} + type="button" + onClick={() => patch({ dataSource: m })} + className={`px-3 py-1 text-sm ${ + shape.dataSource === m + ? "bg-blue-600 text-white" + : "bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800" + }`} + > + {m === "metadata" ? "Metadata" : "Search matches"} + </button> + ))} + </div> + </div> + + <div className="relative self-end"> + <button + type="button" + data-testid="chart-examples-button" + onClick={() => setShowExamples((v) => !v)} + className="rounded-md border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800" + > + Examples ▾ + </button> + {showExamples && ( + <div + data-testid="chart-examples-menu" + className="absolute right-0 z-10 mt-1 w-64 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1 shadow-lg" + > + {PRESET_GROUPS.map((group) => ( + <div key={group.id} className="py-1"> + <div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500"> + {group.title} + </div> + {group.templates.map((t) => ( + <button + key={t.id} + type="button" + onClick={() => { + onChange(shapeFromConfig(t.config, shape)); + setShowExamples(false); + }} + className="block w-full rounded px-2 py-1 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800" + > + {t.title} + </button> + ))} + </div> + ))} + </div> + )} + </div> + </div> + + <div className="flex flex-wrap gap-4"> + <label className={lbl}> + Chart type + <select + className={sel} + value={shape.type} + onChange={(e) => patch({ type: e.target.value as ChartType })} + > + <option value="line">Line</option> + <option value="bar">Bar</option> + <option value="stackedBar">Stacked bar</option> + <option value="area">Area</option> + <option value="pie">Pie</option> + </select> + </label> + + <label className={lbl}> + Group into series by + <select + className={sel} + value={shape.groupBy} + onChange={(e) => patch({ groupBy: e.target.value as SeriesGroupBy })} + > + <option value="none">None</option> + <option value="channel">Channel</option> + <option value="platform">Platform</option> + <option value="language">Language</option> + <option value="mediaType">Media type</option> + <option value="status">Status</option> + </select> + </label> + + <label className="flex items-center gap-2 self-end text-sm"> + <input + type="checkbox" + checked={shape.cumulative} + onChange={(e) => patch({ cumulative: e.target.checked })} + /> + Cumulative + </label> + </div> + + <fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-zinc-200 dark:border-zinc-800 p-3"> + <legend className="px-1 text-xs font-semibold">X axis</legend> + <label className={lbl}> + Dimension + <select + className={sel} + value={shape.x.kind} + onChange={(e) => { + const k = e.target.value; + if (k === "time") patchX({ kind: "time", bin: "month" }); + else if (k === "category") + patchX({ kind: "category", field: "channel" }); + else + patchX({ + kind: "numericBucket", + field: "duration", + bucketSize: 300, + }); + }} + > + <option value="time">Upload date</option> + <option value="category">Category</option> + <option value="numericBucket">Duration buckets</option> + </select> + </label> + {shape.x.kind === "time" && ( + <label className={lbl}> + Bin + <select + className={sel} + value={shape.x.bin} + onChange={(e) => + patchX({ kind: "time", bin: e.target.value as TimeBin }) + } + > + <option value="week">Week</option> + <option value="month">Month</option> + <option value="quarter">Quarter</option> + <option value="year">Year</option> + </select> + </label> + )} + {shape.x.kind === "category" && ( + <label className={lbl}> + Field + <select + className={sel} + value={shape.x.field} + onChange={(e) => + patchX({ + kind: "category", + field: e.target.value as CategoryField, + }) + } + > + <option value="channel">Channel</option> + <option value="platform">Platform</option> + <option value="language">Language</option> + <option value="category">Category</option> + <option value="type">Type (video/livestream)</option> + <option value="mediaType">Media type (incl. shorts)</option> + <option value="status">Status (available/deleted)</option> + <option value="tag">Tag</option> + <option value="hasTranscript">Has transcript</option> + </select> + </label> + )} + {shape.x.kind === "numericBucket" && ( + <label className={lbl}> + Bucket (minutes) + <input + type="number" + min={1} + className={`${sel} w-24`} + value={Math.round(shape.x.bucketSize / 60)} + onChange={(e) => + patchX({ + kind: "numericBucket", + field: "duration", + bucketSize: Math.max(1, Number(e.target.value)) * 60, + }) + } + /> + </label> + )} + </fieldset> + + {shape.dataSource === "metadata" && ( + <fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-zinc-200 dark:border-zinc-800 p-3"> + <legend className="px-1 text-xs font-semibold">Y metric</legend> + <label className={lbl}> + Aggregate + <select + className={sel} + value={shape.y.agg} + onChange={(e) => { + const agg = e.target.value; + if (agg === "count") patchY({ agg: "count" }); + else + patchY({ + agg: agg as Exclude<YMetric, { agg: "count" }>["agg"], + field: shape.y.agg === "count" ? "viewCount" : shape.y.field, + }); + }} + > + <option value="count">Count</option> + <option value="sum">Sum</option> + <option value="avg">Average</option> + <option value="median">Median</option> + <option value="max">Max</option> + <option value="min">Min</option> + </select> + </label> + {shape.y.agg !== "count" && ( + <label className={lbl}> + Of + <select + className={sel} + value={shape.y.field} + onChange={(e) => + patchY({ + agg: ( + shape.y as { + agg: Exclude<YMetric, { agg: "count" }>["agg"]; + } + ).agg, + field: e.target.value as MetricField, + }) + } + > + {METRIC_FIELDS.map((m) => ( + <option key={m.value} value={m.value}> + {m.label} + </option> + ))} + </select> + </label> + )} + </fieldset> + )} + + {shape.dataSource === "search" && ( + <label className={`${lbl} self-start`}> + Plot + <select + className={sel} + value={shape.metric} + onChange={(e) => + patch({ + metric: e.target.value as "matchedVideos" | "totalHits", + }) + } + > + <option value="matchedVideos">Matched videos</option> + <option value="totalHits">Total hits</option> + </select> + </label> + )} + </div> + ); +} diff --git a/common/components/charts/SearchChartPanel.tsx b/common/components/charts/SearchChartPanel.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useMemo } from "react"; +import { useStats } from "../statsCache"; +import type { VideoStat } from "../../lib/stats"; +import { aggregate, type ChartData } from "../../lib/chartAggregate"; +import { seriesFromSlugs } from "../../lib/chartSeriesFromSlugs"; +import type { ChartConfig } from "../../lib/chartConfig"; +import type { ChartShape } from "../../lib/chartShare"; +import { ChartView } from "./ChartView"; + +const EMPTY: ChartData = { categories: [], series: [] }; + +// Renders the single search-page chart. Loads the stats dataset lazily (only +// mounted when the chart view is active), then either bins the live search +// results (`slugs`/`hitsBySlug` from the page's treeProgress) or aggregates +// metadata over the in-scope videos. Reuses ChartView unchanged. +export function SearchChartPanel({ + slugs, + hitsBySlug, + scopeSlugs, + shape, + loading, +}: { + // Search mode: matched slugs (already filter-scoped). Browse mode: in-scope slugs. + slugs: Iterable<string>; + // Per-slug hit lists from treeProgress; null when there's no active query. + hitsBySlug: ReadonlyMap<string, ArrayLike<unknown>> | null; + // In-scope slugs for metadata aggregation (respects committed filters). + scopeSlugs: readonly string[]; + shape: ChartShape; + loading: boolean; +}) { + const { stats, ready, loadedPages, pageCount, error } = useStats(); + + const statBySlug = useMemo(() => { + const m = new Map<string, VideoStat>(); + for (const s of stats) m.set(s.slug, s); + return m; + }, [stats]); + + // ChartView wants a full ChartConfig; build a throwaway one from the shape. + // The `search` block is only used by yAxisLabel to title the axis. + const config = useMemo<ChartConfig>( + () => ({ + id: "live-chart", + title: "", + type: shape.type, + x: shape.x, + y: shape.y, + groupBy: shape.groupBy, + cumulative: shape.cumulative, + filters: {}, + search: + shape.dataSource === "search" + ? { queryTree: "", metric: shape.metric } + : undefined, + }), + [shape], + ); + + const { data, charted, hits } = useMemo(() => { + if (!ready) return { data: EMPTY, charted: 0, hits: 0 }; + if (shape.dataSource === "metadata") { + const scope = new Set(scopeSlugs); + const scoped = stats.filter((s) => scope.has(s.slug)); + return { data: aggregate(scoped, config), charted: scoped.length, hits: 0 }; + } + let chartedCount = 0; + let hitTotal = 0; + const eligible: string[] = []; + const hitCount = (slug: string) => hitsBySlug?.get(slug)?.length ?? 0; + for (const slug of slugs) { + if (!statBySlug.has(slug)) continue; + chartedCount += 1; + hitTotal += hitCount(slug); + eligible.push(slug); + } + const series = seriesFromSlugs(eligible, statBySlug, shape, (slug) => + shape.metric === "totalHits" ? hitCount(slug) : 1, + ); + return { data: series, charted: chartedCount, hits: hitTotal }; + }, [ready, shape, stats, statBySlug, slugs, hitsBySlug, scopeSlugs, config]); + + if (error) { + return ( + <p className="text-sm text-red-600 dark:text-red-400"> + Failed to load the stats dataset ({error.message}). It may not have been + built yet — run the stats build. + </p> + ); + } + if (!ready) { + return ( + <p className="text-sm text-zinc-500"> + Loading stats…{" "} + {pageCount > 0 ? `(${loadedPages}/${pageCount} pages)` : ""} + </p> + ); + } + + return ( + <div className="flex flex-col gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-3"> + <ChartView data={data} config={config} /> + <p className="text-xs text-zinc-500" data-testid="chart-caption"> + {charted} {charted === 1 ? "video" : "videos"} + {shape.dataSource === "search" ? ` · ${hits} hits` : ""} + {loading ? " · searching…" : ""} + </p> + </div> + ); +} diff --git a/common/components/charts/useSearchSeries.ts b/common/components/charts/useSearchSeries.ts @@ -4,13 +4,8 @@ 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 { - xLabelsForStat, - seriesKeyForStat, - type ChartData, - type ChartSeries, -} from "../../lib/chartAggregate"; -import { applyFilters } from "../../lib/chartAggregate"; +import { applyFilters, type ChartData } from "../../lib/chartAggregate"; +import { seriesFromSlugs } from "../../lib/chartSeriesFromSlugs"; import { parseRoot } from "../../lib/searchQuery"; import { runQueryTree, type TreeProgress } from "../../lib/searchEval"; @@ -93,76 +88,27 @@ export function useSearchSeries( if (!progress) { return { data: EMPTY, loading: true, matchedVideos: 0, totalHits: 0, error }; } - // seriesKey -> xLabel -> { value, sort } - const buckets = new Map< - string, - Map<string, { value: number; sort: number | string }> - >(); + // 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; - const hitCount = progress.hits.get(slug)?.length ?? 0; matchedVideos += 1; - totalHits += hitCount; - const weight = search.metric === "totalHits" ? hitCount : 1; - const sKey = seriesKeyForStat(stat, config.groupBy); - let xMap = buckets.get(sKey); - if (!xMap) { - xMap = new Map(); - buckets.set(sKey, xMap); - } - for (const { label, sort } of xLabelsForStat(stat, config.x)) { - const cell = xMap.get(label); - if (cell) cell.value += weight; - else xMap.set(label, { value: weight, sort }); - } + totalHits += progress.hits.get(slug)?.length ?? 0; + eligible.push(slug); } - - const sortByLabel = new Map<string, number | string>(); - for (const xMap of buckets.values()) - for (const [label, cell] of xMap) sortByLabel.set(label, cell.sort); - const categories = Array.from(sortByLabel.keys()); - if (config.x.kind === "category") { - const total = (label: string) => { - let t = 0; - for (const xMap of buckets.values()) t += xMap.get(label)?.value ?? 0; - return t; - }; - categories.sort((a, b) => total(b) - total(a)); - } else { - categories.sort((a, b) => { - const sa = sortByLabel.get(a)!; - const sb = sortByLabel.get(b)!; - return typeof sa === "number" && typeof sb === "number" - ? sa - sb - : String(sa).localeCompare(String(sb)); - }); - } - - const series: ChartSeries[] = []; - for (const [sKey, xMap] of buckets) { - let running = 0; - const points = categories.map((label) => { - const v = xMap.get(label)?.value ?? 0; - if (config.cumulative) { - running += v; - return { x: label, y: running }; - } - return { x: label, y: v }; - }); - series.push({ key: sKey, points }); - } - series.sort( - (a, b) => - b.points.reduce((s, p) => s + p.y, 0) - - a.points.reduce((s, p) => s + p.y, 0), + const data = seriesFromSlugs(eligible, statBySlug, config, (slug) => + search.metric === "totalHits" ? progress.hits.get(slug)?.length ?? 0 : 1, ); return { - data: { categories, series }, + data, loading: !progress.done, matchedVideos, totalHits, diff --git a/common/components/searchPipeline.ts b/common/components/searchPipeline.ts @@ -36,6 +36,10 @@ type PipelineConfig = { concurrency: number; flushIntervalMs: number; emit: (update: PipelineUpdate) => void; + // Which field of the fetched transcript document to match. "cues" (default) + // is the transcripts scope; "description"/"tags" match those metadata fields. + // All reuse the same per-channel transcript-page fetch + worker pool. + matchField?: "cues" | "description" | "tags"; }; export type PipelineController = { @@ -58,6 +62,7 @@ export function createSearchPipeline( concurrency, flushIntervalMs, emit, + matchField = "cues", } = config; let cancelled = false; @@ -118,15 +123,21 @@ export function createSearchPipeline( try { const full = await fetchTranscript(slug); if (cancelled) return; - if (full.cues && totalSoFar < hitLimit) { + if (totalSoFar < hitLimit) { const remaining = hitLimit - totalSoFar; - const hits = findHitsInCues( - full.cues, - query, - useRegex, - regex, - remaining, - ); + const hits = + matchField === "description" + ? findHitsInText(full.description ?? "", query, useRegex, regex) + : matchField === "tags" + ? findHitsInText( + (full.tags ?? []).join(", "), + query, + useRegex, + regex, + ) + : full.cues + ? findHitsInCues(full.cues, query, useRegex, regex, remaining) + : []; if (hits.length > 0) { localHits[slug] = hits; totalSoFar += hits.length; @@ -219,6 +230,27 @@ function findHitsInCues( return hits; } +// Single-document text match (description scope): emit at most one hit with a +// snippet window around the first match, so the result row shows context. +function findHitsInText( + text: string, + query: string, + useRegex: boolean, + regex: RegExp | null, +): Hit[] { + if (!text) return []; + const m = findFirstMatchInRange(text, 0, text.length, query, useRegex, regex); + if (!m) return []; + const PAD = 80; + const from = Math.max(0, m.idx - PAD); + const to = Math.min(text.length, m.idx + m.length + PAD); + const snippet = + (from > 0 ? "…" : "") + + text.slice(from, to).replace(/\s+/g, " ").trim() + + (to < text.length ? "…" : ""); + return [{ start: 0, text: snippet }]; +} + function findFirstMatchInRange( haystack: string, rangeStart: number, @@ -533,7 +565,7 @@ export function runLeafPipeline(opts: { slug, list.map((h) => ({ leafId: leaf.id, - scope: "transcripts" as const, + scope: leaf.scope, start: h.start, text: h.text, })), @@ -592,7 +624,11 @@ export function runLeafPipeline(opts: { }; let controller: PipelineController; - if (leaf.scope === "transcripts") { + if ( + leaf.scope === "transcripts" || + leaf.scope === "description" || + leaf.scope === "tags" + ) { controller = createSearchPipeline({ slugs: scopeSlugs, query: trimmed, @@ -601,6 +637,12 @@ export function runLeafPipeline(opts: { initialHitLimit, concurrency, flushIntervalMs, + matchField: + leaf.scope === "description" + ? "description" + : leaf.scope === "tags" + ? "tags" + : "cues", emit: (u) => onProgress(adaptTranscriptUpdate(u)), }); } else if (leaf.scope === "chat") { diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -86,7 +86,9 @@ import { AVAILABILITY_FILENAME } from "../lib/availability"; // v10: multi-site build. Shared per-channel transcript/subs pages are written // once; per-site summaries + subs manifests are filtered selections. Bumped to // force a clean rebuild into the new shared/ + sites/ staging layout. -const SCHEMA_VERSION = 10; +// v11: transcript pages now carry `tags` (for the "tags" search scope) — force +// a re-extract so existing pages re-emit with the field. +const SCHEMA_VERSION = 11; // Per-channel subtitle stats, collected while writing the shared subs pages and // persisted to LMDB so per-site subs manifests can be assembled on a no-op diff --git a/common/controller/buildStats.ts b/common/controller/buildStats.ts Binary files differ. diff --git a/common/lib/chartAggregate.ts b/common/lib/chartAggregate.ts @@ -128,12 +128,37 @@ export function xLabelsForStat( const cats = stat.categories.length ? stat.categories : [EMPTY]; return cats.map((c) => ({ label: c, sort: c })); } - const v = - x.field === "channel" - ? stat.channel || stat.channelSlug - : x.field === "platform" - ? stat.platform - : stat.language || EMPTY; + if (x.field === "tag") { + // A video contributes to each of its tags (multi-value, like category). + const tags = stat.tags.length ? stat.tags : [EMPTY]; + return tags.map((t) => ({ label: t, sort: t })); + } + let v: string; + switch (x.field) { + case "channel": + v = stat.channel || stat.channelSlug; + break; + case "platform": + v = stat.platform; + break; + case "language": + v = stat.language || EMPTY; + break; + case "type": + v = stat.isLivestream ? "Livestream" : "Video"; + break; + case "hasTranscript": + v = stat.hasTranscript ? "Has transcript" : "No transcript"; + break; + case "mediaType": + v = stat.mediaType; + break; + case "status": + v = stat.status; + break; + default: + v = EMPTY; + } return [{ label: v, sort: v }]; } } @@ -156,6 +181,10 @@ export function seriesKeyForStat(stat: VideoStat, groupBy: SeriesGroupBy): strin return stat.platform; case "language": return stat.language || EMPTY; + case "mediaType": + return stat.mediaType; + case "status": + return stat.status; case "none": return SERIES_ALL; } diff --git a/common/lib/chartConfig.ts b/common/lib/chartConfig.ts @@ -8,7 +8,16 @@ import { newGroup, newLeaf, stringifyRoot } from "./searchQuery"; export type TimeBin = "week" | "month" | "quarter" | "year"; -export type CategoryField = "channel" | "platform" | "language" | "category"; +export type CategoryField = + | "channel" + | "platform" + | "language" + | "category" + | "type" + | "hasTranscript" + | "tag" + | "mediaType" + | "status"; export type NumericBucketField = "duration"; export type XAxisField = @@ -30,7 +39,13 @@ export type YMetric = | { agg: Exclude<MetricAgg, "count">; field: MetricField }; export type ChartType = "line" | "bar" | "area" | "stackedBar" | "pie"; -export type SeriesGroupBy = "none" | "channel" | "platform" | "language"; +export type SeriesGroupBy = + | "none" + | "channel" + | "platform" + | "language" + | "mediaType" + | "status"; export type ChartFilters = { dateFrom?: string; // YYYYMMDD inclusive @@ -128,6 +143,11 @@ const CATEGORY_FIELD_LABEL: Record<CategoryField, string> = { platform: "Platform", language: "Language", category: "Category", + type: "Type", + hasTranscript: "Transcript", + tag: "Tag", + mediaType: "Media type", + status: "Status", }; const METRIC_FIELD_LABEL: Record<MetricField, string> = { diff --git a/common/lib/chartSeriesFromSlugs.ts b/common/lib/chartSeriesFromSlugs.ts @@ -0,0 +1,89 @@ +// Pure binning of a set of matched/in-scope slugs into chart-ready series, +// shared by the search-page chart view (bins the live `treeProgress` results) +// and the search-derived dashboard hook (`useSearchSeries`). It reuses the same +// x-axis labelling and series grouping as metadata charts (chartAggregate.ts) +// so search and metadata charts look identical. +// +// Callers MUST pre-scope `slugs` (e.g. to filter-passing, stat-present videos); +// this function does NOT re-apply chart filters — re-filtering would double- +// filter and silently drop slugs absent from the stats dataset. + +import type { VideoStat } from "./stats"; +import type { ChartConfig } from "./chartConfig"; +import { + xLabelsForStat, + seriesKeyForStat, + type ChartData, + type ChartSeries, +} from "./chartAggregate"; + +export function seriesFromSlugs( + slugs: Iterable<string>, + statBySlug: ReadonlyMap<string, VideoStat>, + shape: Pick<ChartConfig, "x" | "groupBy" | "cumulative">, + weightForSlug: (slug: string) => number, +): ChartData { + // seriesKey -> xLabel -> { value, sort } + const buckets = new Map< + string, + Map<string, { value: number; sort: number | string }> + >(); + for (const slug of slugs) { + const stat = statBySlug.get(slug); + if (!stat) continue; + const weight = weightForSlug(slug); + const sKey = seriesKeyForStat(stat, shape.groupBy); + let xMap = buckets.get(sKey); + if (!xMap) { + xMap = new Map(); + buckets.set(sKey, xMap); + } + for (const { label, sort } of xLabelsForStat(stat, shape.x)) { + const cell = xMap.get(label); + if (cell) cell.value += weight; + else xMap.set(label, { value: weight, sort }); + } + } + + const sortByLabel = new Map<string, number | string>(); + for (const xMap of buckets.values()) + for (const [label, cell] of xMap) sortByLabel.set(label, cell.sort); + const categories = Array.from(sortByLabel.keys()); + if (shape.x.kind === "category") { + const total = (label: string) => { + let t = 0; + for (const xMap of buckets.values()) t += xMap.get(label)?.value ?? 0; + return t; + }; + categories.sort((a, b) => total(b) - total(a)); + } else { + categories.sort((a, b) => { + const sa = sortByLabel.get(a)!; + const sb = sortByLabel.get(b)!; + return typeof sa === "number" && typeof sb === "number" + ? sa - sb + : String(sa).localeCompare(String(sb)); + }); + } + + const series: ChartSeries[] = []; + for (const [sKey, xMap] of buckets) { + let running = 0; + const points = categories.map((label) => { + const v = xMap.get(label)?.value ?? 0; + if (shape.cumulative) { + running += v; + return { x: label, y: running }; + } + return { x: label, y: v }; + }); + series.push({ key: sKey, points }); + } + series.sort( + (a, b) => + b.points.reduce((s, p) => s + p.y, 0) - + a.points.reduce((s, p) => s + p.y, 0), + ); + + return { categories, series }; +} diff --git a/common/lib/chartShare.ts b/common/lib/chartShare.ts @@ -17,9 +17,29 @@ import { type YMetric, } from "./chartConfig"; +// The presentation-only description of the search page's single chart. Unlike a +// ChartConfig it owns NO query (that lives in `qt=`) and NO filters (those live +// in the search share params) — only how to plot the current search/metadata. +export type ChartShape = { + dataSource: "search" | "metadata"; + type: ChartType; + x: XAxisField; + y: YMetric; // only meaningful when dataSource === "metadata" + groupBy: SeriesGroupBy; + cumulative: boolean; + metric: "matchedVideos" | "totalHits"; // only meaningful when dataSource === "search" +}; + const TYPES: ChartType[] = ["line", "bar", "area", "stackedBar", "pie"]; const BINS: TimeBin[] = ["week", "month", "quarter", "year"]; -const GROUPS: SeriesGroupBy[] = ["none", "channel", "platform", "language"]; +const GROUPS: SeriesGroupBy[] = [ + "none", + "channel", + "platform", + "language", + "mediaType", + "status", +]; type SC = Record<string, unknown>; @@ -76,7 +96,21 @@ function decX(raw: unknown): XAxisField { if (r.k === "c") { return { kind: "category", - field: oneOf(r.f, ["channel", "platform", "language", "category"], "channel"), + field: oneOf( + r.f, + [ + "channel", + "platform", + "language", + "category", + "type", + "hasTranscript", + "tag", + "mediaType", + "status", + ], + "channel", + ), }; } return { kind: "time", bin: oneOf(r.b, BINS, "month") }; @@ -166,3 +200,44 @@ export function parseDashboard(s: string): Dashboard | null { charts, }; } + +// ─── Chart shape (search-page single chart) ─── +// Reuses the chart enc/dec helpers above; the `cs=` URL param holds this. +export function defaultChartShape(dataSource: "search" | "metadata"): ChartShape { + return { + dataSource, + type: "line", + x: { kind: "time", bin: "month" }, + y: { agg: "count" }, + groupBy: "none", + cumulative: false, + metric: "matchedVideos", + }; +} + +export function stringifyChartShape(s: ChartShape): string { + const out: SC = { ds: s.dataSource, ty: s.type, x: encX(s.x), y: encY(s.y) }; + if (s.groupBy !== "none") out.g = s.groupBy; + if (s.cumulative) out.cu = 1; + if (s.metric !== "matchedVideos") out.m = s.metric; + return JSON.stringify(out); +} + +export function parseChartShape(s: string): ChartShape { + let raw: unknown; + try { + raw = JSON.parse(s); + } catch { + return defaultChartShape("search"); + } + const r = (raw && typeof raw === "object" ? raw : {}) as SC; + return { + dataSource: oneOf(r.ds, ["search", "metadata"], "search"), + type: oneOf(r.ty, TYPES, "line"), + x: decX(r.x), + y: decY(r.y), + groupBy: oneOf(r.g, GROUPS, "none"), + cumulative: r.cu === 1, + metric: oneOf(r.m, ["matchedVideos", "totalHits"], "matchedVideos"), + }; +} diff --git a/common/lib/searchQuery.ts b/common/lib/searchQuery.ts @@ -11,7 +11,12 @@ import type { SearchMode } from "../components/urlState"; -export type LayerScope = "transcripts" | "chat" | "metadata"; +export type LayerScope = + | "transcripts" + | "chat" + | "metadata" + | "description" + | "tags"; export type GroupOp = "AND" | "OR"; @@ -182,7 +187,13 @@ function deserialize(raw: unknown): QueryNode | null { const r = raw as Record<string, unknown>; if (r.k === "l") { const scope = r.s; - if (scope !== "transcripts" && scope !== "chat" && scope !== "metadata") { + if ( + scope !== "transcripts" && + scope !== "chat" && + scope !== "metadata" && + scope !== "description" && + scope !== "tags" + ) { return null; } return { diff --git a/common/lib/stats.ts b/common/lib/stats.ts @@ -2,9 +2,17 @@ import type { Platform } from "./platform"; // Bumping this invalidates the LMDB `statsByPath` incremental cache and forces // a full re-extraction (e.g. when a new field is added below). -export const STATS_SCHEMA_VERSION = 2; +export const STATS_SCHEMA_VERSION = 3; export const STATS_MANIFEST_VERSION = 1; +// Visibility of a video on its source platform, derived from the availability +// check (available = still public, unlisted, or fully removed/deleted). +export type VideoStatus = "available" | "unlisted" | "deleted"; + +// Coarse media kind. YouTube distinguishes shorts; other platforms only carry +// video vs. livestream, so they fall back to one of those. +export type MediaType = "video" | "livestream" | "short"; + // Per-page byte cap for the served stats index. Static hosts limit individual // file sizes (Cloudflare Pages = 25 MB; smaller elsewhere), so pages are // flushed by byte size rather than a fixed record count. Kept well under 25 MB. @@ -31,8 +39,11 @@ export type VideoStat = { commentCount: number | null; channelFollowerCount: number | null; categories: string[]; + tags: string[]; language: string | null; isLivestream: boolean; + mediaType: MediaType; + status: VideoStatus; hasTranscript: boolean; cueCount: number | null; }; diff --git a/common/lib/transcripts-server.ts b/common/lib/transcripts-server.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { formatDate, formatDuration } from "./format"; import { defaultWebpageUrl } from "./platform"; import type { DisplaySummary, Platform, TranscriptSummary } from "./transcripts"; -import type { VideoStat } from "./stats"; +import type { MediaType, VideoStat, VideoStatus } from "./stats"; export type RawMetadata = { id?: string; @@ -29,7 +29,11 @@ export type RawMetadata = { comment_count?: number; channel_follower_count?: number; categories?: string[]; + tags?: string[]; language?: string; + // yt-dlp's coarse kind: "video" | "livestream" | "short" (YouTube). Absent on + // platforms that don't distinguish, where we fall back to is/was_live. + media_type?: string; }; // Read and parse a video's metadata.info.json into the typed RawMetadata @@ -91,6 +95,7 @@ export function summarize( duration: meta.duration ?? 0, channel: configName ?? meta.channel ?? meta.uploader ?? "", description: meta.description ?? "", + tags: tagsOf(meta), isLivestream, ageRestricted: (meta.age_limit ?? 0) > 0, platform, @@ -98,6 +103,20 @@ export function summarize( }; } +function tagsOf(meta: RawMetadata): string[] { + return Array.isArray(meta.tags) + ? meta.tags.filter((t): t is string => typeof t === "string") + : []; +} + +// Coarse media kind for charts. YouTube tags shorts via media_type; everything +// else collapses to livestream (from the is/was_live signal) or plain video. +function mediaTypeOf(meta: RawMetadata, isLivestream: boolean): MediaType { + if (isLivestream) return "livestream"; + if (meta.media_type === "short") return "short"; + return "video"; +} + function numOrNull(v: unknown): number | null { return typeof v === "number" && Number.isFinite(v) ? v : null; } @@ -111,7 +130,11 @@ export function summarizeStats( videoDir: string, meta: RawMetadata, configName: string | undefined, - transcript: { hasTranscript: boolean; cueCount: number | null }, + transcript: { + hasTranscript: boolean; + cueCount: number | null; + status: VideoStatus; + }, ): VideoStat { const base = summarize(channelSlug, videoDir, meta, configName); return { @@ -131,8 +154,11 @@ export function summarizeStats( categories: Array.isArray(meta.categories) ? meta.categories.filter((c): c is string => typeof c === "string") : [], + tags: base.tags, language: typeof meta.language === "string" ? meta.language : null, isLivestream: base.isLivestream, + mediaType: mediaTypeOf(meta, base.isLivestream), + status: transcript.status, hasTranscript: transcript.hasTranscript, cueCount: transcript.cueCount, }; diff --git a/common/lib/transcripts.ts b/common/lib/transcripts.ts @@ -15,6 +15,9 @@ export type TranscriptSummary = { duration: number; channel: string; description: string; + // Keyword tags from metadata.info.json (YouTube/Odysee), searchable via the + // "tags" scope. Empty when the platform/archive carries none. + tags: string[]; isLivestream: boolean; ageRestricted: boolean; platform: Platform; diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +- **Search video descriptions and tags.** Each search layer's scope dropdown gains two new options alongside Transcripts / Live chat / Title-channel: **Description** (matches the video's description text) and **Tags** (matches its keyword tags). They combine with the others through the same AND/OR/NOT query builder and regex, and — like any search — can be charted (e.g. videos whose *description* mentions a term, over time). Because descriptions/tags live in the per-video transcript data, a description/tags-only search loads the same data a transcript search does. +- **New chart breakdowns: Media type, Status, and Tag.** Charts can now split or bucket videos by **Media type** (regular video / livestream / **Short** — Shorts were previously indistinguishable), by **Status** (available / unlisted / deleted — e.g. chart how many videos got deleted each month), and by **Tag** (a bar per keyword tag). Media type and Status are also available as series groupings. (Populating these for existing videos requires a one-time data rebuild — `build:index` + `build:stats` re-extract on the bumped schema versions.) +- **Charts are now a view of Search.** The separate Charts dashboard is gone; instead, a **Results / Chart** toggle sits beside the result count on the search page. Flip to **Chart** and the current search — query, every filter, and your advanced settings — is plotted as a single chart, updating live as results stream in. A **Chart options** panel (right where the Filters panel lives) configures it: chart type (line/bar/stacked/area/pie), X axis (upload date binned by week/month/quarter/year, category, or duration buckets), split into series by channel/platform/language, cumulative running totals, and a **data source** switch between **Search matches** (plot matched videos or total hits) and **Metadata** (uploads, views, durations, top channels — aggregated over the same in-scope videos). An **Examples ▾** menu one-click loads common chart shapes. Switching between Results and Chart keeps everything in place — it's the same search — and **Share current search** now encodes the chart too (`view=chart` + a compact `cs=` shape), so a shared link opens straight to the same chart; the URL stays in sync live, just like the query does. The old standalone `/charts` page and its multi-chart board are retired on the site (dashboard authoring still lives in the editor). - **Footer links to the network's other sites.** The footer now carries a subtle, low-key list of links to the *other* sites in the network (e.g. Jeralyzer's footer links to Rekietalyzer and Hasanalyzer). It's intentionally understated — a small, muted row beneath the downloads/social row — since it's a secondary wayfinding aid, not a primary feature. By default it's a flat list of every sibling site; a site can override that to surface closely-related subjects first under named groups (configured per site in the editor), with everything else collected under a trailing "Other sites" group. Only sites that have a public URL configured appear, and a site never links to itself. Single-site installs show nothing new. ## [0.3.5] - 2026-06-04 diff --git a/export/app/charts/ChartsClient.tsx b/export/app/charts/ChartsClient.tsx @@ -1,66 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useStats } from "yt-dlp-transcript-common/components/statsCache"; -import { useSummaries } from "yt-dlp-transcript-common/components/summariesCache"; -import { ChartsDashboard } from "yt-dlp-transcript-common/components/charts/ChartsDashboard"; -import { - defaultTemplates, - PRESET_GROUPS, - type ChartTemplates, -} from "yt-dlp-transcript-common/lib/chartConfig"; - -export function ChartsClient() { - const { stats, ready, loadedPages, pageCount, manifest, error } = useStats(); - const { summaries } = useSummaries(); - const [templates, setTemplates] = useState<ChartTemplates | null>(null); - - // Editor-authored templates are baked into the static build; fall back to - // the built-in presets when absent. - useEffect(() => { - let alive = true; - fetch("/chart-templates.json") - .then((r) => (r.ok ? r.json() : null)) - .then((t: ChartTemplates | null) => { - if (alive) setTemplates(t ?? defaultTemplates()); - }) - .catch(() => { - if (alive) setTemplates(defaultTemplates()); - }); - return () => { - alive = false; - }; - }, []); - - if (error) { - return ( - <p className="text-sm text-red-600 dark:text-red-400"> - Failed to load the stats dataset ({error.message}). It may not have been - built yet — run the stats build. - </p> - ); - } - if (!ready || !templates) { - return ( - <p className="text-sm text-zinc-500"> - Loading stats… {pageCount > 0 ? `(${loadedPages}/${pageCount} pages)` : ""} - </p> - ); - } - - const channels = (manifest?.channels ?? []).map((c) => ({ - slug: c.slug, - name: c.name, - })); - - return ( - <ChartsDashboard - initialDashboard={templates.defaultDashboard} - gallery={PRESET_GROUPS} - stats={stats} - summaries={summaries} - channels={channels} - syncUrl - /> - ); -} diff --git a/export/app/charts/page.tsx b/export/app/charts/page.tsx @@ -1,19 +0,0 @@ -import type { Metadata } from "next"; -import { ChartsClient } from "./ChartsClient"; - -export const metadata: Metadata = { title: "Charts" }; - -export default function ChartsPage() { - return ( - <div className="flex flex-col gap-4"> - <div> - <h1 className="text-2xl font-semibold">Charts</h1> - <p className="mt-1 text-sm text-zinc-500"> - Build charts from video metadata and search matches. Edit any chart, - add from the gallery, and share the board with a link. - </p> - </div> - <ChartsClient /> - </div> - ); -} diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -54,12 +54,6 @@ export default async function RootLayout({ Search </Link> <Link - href="/charts" - className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400" - > - Charts - </Link> - <Link href="/duplicates" className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400" > diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts @@ -1,219 +1,170 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { installChartRoutes, urlParams } from "./helpers"; -// Charts page e2e. Stats dataset, baked templates, summaries and transcripts -// are all route-mocked (see installChartRoutes); the page itself drives real -// aggregation, the search engine, URL sharing, and recharts rendering. +// Charts are now a VIEW MODE of the search page: a "Results | Chart" toggle +// plots the current search/filters as a single chart. Stats, summaries and +// transcripts are route-mocked (installChartRoutes); the page drives real +// aggregation, the search engine, URL sharing and recharts rendering. -test.describe("charts", () => { - test.beforeEach(async ({ page }) => { - await installChartRoutes(page); - }); +test.use({ permissions: ["clipboard-read", "clipboard-write"] }); - test("renders the default dashboard from templates", async ({ page }) => { - await page.goto("/charts"); - await expect(page.getByRole("heading", { name: "Uploads per month" })).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Total views per month" }), - ).toBeVisible(); - await expect(page.locator(".recharts-surface")).toHaveCount(2); - }); +async function runSearch(page: Page, term: string) { + const box = page.getByPlaceholder("Search transcripts..."); + await expect(box).toBeVisible(); + await box.fill(term); + await box.press("Enter"); + await expect(page).toHaveURL(/[?&]qt=/); +} - test("dashboard platform filter with no matches empties the charts", async ({ - page, - }) => { - await page.goto("/charts"); - await expect(page.locator(".recharts-surface")).toHaveCount(2); - // Fixtures are all "rumble"; selecting youtube should drop everything. - await page.getByRole("button", { name: "Youtube" }).click(); - await expect( - page.getByText("No data for this configuration.").first(), - ).toBeVisible(); - }); +function chartTab(page: Page) { + return page.getByTestId("view-toggle").getByRole("button", { name: "Chart" }); +} - test("add a chart from the gallery", async ({ page }) => { - await page.goto("/charts"); - await expect(page.locator(".recharts-surface")).toHaveCount(2); - await page.getByRole("button", { name: "Add chart" }).click(); - await page - .getByRole("button", { name: "Top channels by video count" }) - .click(); - await expect(page.locator(".recharts-surface")).toHaveCount(3); - }); - - test("share link round-trips the dashboard", async ({ page }) => { - await page.goto("/charts"); - await expect(page.locator(".recharts-surface")).toHaveCount(2); - // Add a chart so the shared board differs from the default. - await page.getByRole("button", { name: "Add chart" }).click(); - await page - .getByRole("button", { name: "Top channels by video count" }) - .click(); - await expect(page.locator(".recharts-surface")).toHaveCount(3); - - await page.getByRole("button", { name: "Share" }).click(); - const params = await urlParams(page); - expect(params.get("dash")).toBeTruthy(); - - await page.reload(); - await expect(page.locator(".recharts-surface")).toHaveCount(3); +test.describe("charts (search view mode)", () => { + test.beforeEach(async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await installChartRoutes(page); }); - test("search-derived chart counts matched videos", async ({ page }) => { - await page.goto("/charts"); - await page.getByRole("button", { name: "New blank" }).click(); - await page.getByRole("button", { name: "Search matches" }).click(); - // The chart editor now embeds the full query builder. - await page.getByTestId("query-builder").getByRole("searchbox").fill("alpha"); - await page.getByRole("button", { name: "Save", exact: true }).click(); - // "alpha" appears once per fixture transcript → 3 videos, 3 hits. - await expect(page.getByText(/3 videos · 3 hits/)).toBeVisible({ + test("Chart toggle plots the current search", async ({ page }) => { + await page.goto("/"); + await runSearch(page, "alpha"); + await chartTab(page).click(); + await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); + // "alpha" hits once per fixture transcript → 3 videos, 3 hits. + await expect(page.getByTestId("chart-caption")).toContainText( + "3 videos · 3 hits", + { timeout: 20_000 }, + ); + // The chart view is reflected in the URL live (shareable like a search). + const params = await urlParams(page); + expect(params.get("view")).toBe("chart"); + expect(params.get("cs")).toBeTruthy(); }); - test("regex search term matches across alternation", async ({ page }) => { - await page.goto("/charts"); - await page.getByRole("button", { name: "New blank" }).click(); - await page.getByRole("button", { name: "Search matches" }).click(); - const builder = page.getByTestId("query-builder"); - await builder.getByRole("checkbox", { name: "Regex" }).check(); - await builder.getByRole("searchbox").fill("alpha|beta"); - await page.getByRole("button", { name: "Save", exact: true }).click(); - // "alpha" and "beta" each appear once per fixture transcript → 3 videos, 6 hits. - await expect(page.getByText(/3 videos · 6 hits/)).toBeVisible({ + test("Chart toggle with no query plots metadata", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("results-summary")).toContainText("All videos"); + await chartTab(page).click(); + await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); - }); - - test("charts render axis labels", async ({ page }) => { - await page.goto("/charts"); - await expect(page.locator(".recharts-surface").first()).toBeVisible(); - // X axis label for a month-binned chart, Y labels for count + sum-views. - await expect(page.getByText("Month", { exact: true }).first()).toBeVisible(); - await expect(page.getByText("Videos", { exact: true }).first()).toBeVisible(); + // Three fixture videos, no active query → metadata count. + await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); + // No query → defaults to the Metadata data source (Y-metric controls show). await expect( - page.getByText("Total views", { exact: true }).first(), + page.getByTestId("chart-options").getByText("Y metric"), ).toBeVisible(); }); - test("editor data-source toggle swaps metadata vs search fields", async ({ + test("data-source toggle swaps metadata vs search controls", async ({ page, }) => { - await page.goto("/charts"); - await page.getByRole("button", { name: "New blank" }).click(); - // Metadata mode by default: Y-metric shown, no query builder. - await expect(page.getByText("Y metric")).toBeVisible(); - await expect(page.getByTestId("query-builder")).toHaveCount(0); - // Switch to Search: query builder shown, Y-metric gone. - await page.getByRole("button", { name: "Search matches" }).click(); - await expect(page.getByTestId("query-builder")).toBeVisible(); - await expect(page.getByText("Y metric")).toHaveCount(0); + await page.goto("/"); + await runSearch(page, "alpha"); + await chartTab(page).click(); + const opts = page.getByTestId("chart-options"); + // Active query → defaults to Search matches: the Plot metric select shows. + await expect(opts.getByText("Plot")).toBeVisible(); + await expect(opts.getByText("Y metric")).toHaveCount(0); + await opts.getByRole("button", { name: "Metadata" }).click(); + await expect(opts.getByText("Y metric")).toBeVisible(); + await expect(opts.getByText("Plot")).toHaveCount(0); }); - test("gallery is grouped and search templates open the editor", async ({ + test("metadata charts can break down by Type (video/livestream)", async ({ page, }) => { - await page.goto("/charts"); - await expect(page.locator(".recharts-surface").first()).toBeVisible(); - await page.getByRole("button", { name: "Add chart" }).click(); - // Group headings inside the gallery panel ("Overview" also appears as the - // dashboard title, so assert the uniquely-named search group). - await expect(page.getByText("Search examples")).toBeVisible(); - // Adding a search starter (empty term) opens the editor to fill it in. - await page.getByRole("button", { name: "Mentions of a term over time" }).click(); - await expect(page.getByTestId("query-builder")).toBeVisible(); + await page.goto("/"); + await expect(page.getByTestId("results-summary")).toContainText("All videos"); + await chartTab(page).click(); + const opts = page.getByTestId("chart-options"); + await opts.locator('label:has-text("Dimension") select').selectOption("category"); + await opts.locator('label:has-text("Field") select').selectOption("type"); + await expect(page.locator(".recharts-surface")).toBeVisible({ + timeout: 20_000, + }); + // All three fixtures are videos (not livestreams) → one "Video" bucket. + await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); }); - test("viewer edits persist across a reload and Reset clears them", async ({ + test("metadata charts can break down by Media type and Status", async ({ page, }) => { - await page.goto("/charts"); - await expect(page.locator(".recharts-surface").first()).toBeVisible(); - // Rename the first chart. - await page.getByRole("button", { name: "Edit" }).first().click(); - await page.locator("label", { hasText: "Title" }).locator("input").fill("PERSISTED CHART"); - await page.getByRole("button", { name: "Save", exact: true }).click(); - await expect(page.getByRole("heading", { name: "PERSISTED CHART" })).toBeVisible(); - - // Fresh navigation with no query param → restored from localStorage. - await page.goto("/charts"); - await expect(page.getByRole("heading", { name: "PERSISTED CHART" })).toBeVisible(); - - // Reset returns to the default board. - await page.getByRole("button", { name: "Reset to default" }).click(); - await expect(page.getByRole("heading", { name: "PERSISTED CHART" })).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Uploads per month" })).toBeVisible(); - }); - - // The search page builds this kind of param via "Chart this search"; here we - // verify the charts page consumes it (opens the editor on the pre-filled chart). - const addChartParam = (mode?: "replace") => { - const chart = JSON.stringify({ - t: "Charted: alpha", - ty: "line", - x: { k: "t", b: "month" }, - y: { a: "count" }, - s: { - q: '{"k":"g","o":"AND","c":[{"k":"l","q":"alpha","s":"transcripts"}]}', - m: "totalHits", - }, + await page.goto("/"); + await expect(page.getByTestId("results-summary")).toContainText("All videos"); + await chartTab(page).click(); + const opts = page.getByTestId("chart-options"); + const dimension = opts.locator('label:has-text("Dimension") select'); + const field = opts.locator('label:has-text("Field") select'); + + // Media type → video / livestream / short (3 fixtures, one of each). + await dimension.selectOption("category"); + await field.selectOption("mediaType"); + await expect(page.locator(".recharts-surface")).toBeVisible({ + timeout: 20_000, }); - return `?addChart=${encodeURIComponent(chart)}${mode === "replace" ? "&chartMode=replace" : ""}`; - }; + await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); - test("addChart handoff adds a pre-filled chart to the board", async ({ - page, - }) => { - await page.goto(`/charts${addChartParam()}`); - // Editor opens on the new chart with the query pre-filled in the builder. - await expect( - page.getByTestId("query-builder").getByRole("searchbox"), - ).toHaveValue("alpha"); - // The default board's charts remain (added, not replaced). - await expect( - page.getByRole("heading", { name: "Uploads per month" }), - ).toBeVisible(); - // The handoff params are stripped from the URL after consumption. - await expect - .poll(() => page.evaluate(() => window.location.search)) - .not.toContain("addChart"); + // Status → available (×2) + deleted (×1). + await field.selectOption("status"); + await expect(page.locator(".recharts-surface")).toBeVisible(); + await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); + + // Tag → multi-valued (one fixture has two tags). + await field.selectOption("tag"); + await expect(page.locator(".recharts-surface")).toBeVisible(); }); - test("addChart handoff with replace mode shows only the charted search", async ({ - page, - }) => { - await page.goto(`/charts${addChartParam("replace")}`); - await expect( - page.getByTestId("query-builder").getByRole("searchbox"), - ).toHaveValue("alpha"); - // Replace mode drops the default board. - await expect( - page.getByRole("heading", { name: "Uploads per month" }), - ).toHaveCount(0); + test("Examples menu sets the chart shape", async ({ page }) => { + await page.goto("/"); + await runSearch(page, "alpha"); + await chartTab(page).click(); + const chartType = page + .getByTestId("chart-options") + .locator("select") + .first(); + await expect(chartType).toHaveValue("line"); // search default + await page.getByTestId("chart-examples-button").click(); + await page + .getByTestId("chart-examples-menu") + .getByRole("button", { name: "Term matches by channel" }) + .click(); + // The "matches by channel" preset is a bar chart. + await expect(chartType).toHaveValue("bar"); }); - // Full round-trip: "Chart this search" on the search page → /charts editor. - // installChartRoutes also mocks the search page's data, so both ends work. - test("Chart this search hands the query off from the search page", async ({ + test("share + reload round-trips the chart view and shape", async ({ page, }) => { await page.goto("/"); - const search = page.getByPlaceholder("Search transcripts..."); - await expect(search).toBeVisible(); - await search.fill("alpha"); - await search.press("Enter"); - await expect(page).toHaveURL(/[?&]qt=/); + await runSearch(page, "alpha"); + await chartTab(page).click(); + const chartType = page + .getByTestId("chart-options") + .locator("select") + .first(); + await chartType.selectOption("bar"); - await page.getByRole("button", { name: /Chart this search/ }).click(); await page - .getByTestId("chart-this-search-menu") - .getByRole("button", { name: "Mentions over time" }) + .getByTestId("profiles-row") + .getByRole("button", { name: "Share current search" }) .click(); + const clip = await page.evaluate(() => navigator.clipboard.readText()); + const url = new URL(clip); + expect(url.searchParams.get("qt")).toBeTruthy(); + expect(url.searchParams.get("view")).toBe("chart"); + expect(url.searchParams.get("cs")).toBeTruthy(); - await expect(page).toHaveURL(/\/charts/); + // A fresh load of the live URL restores chart view + the bar shape. + await page.reload(); + await expect(page.locator(".recharts-surface")).toBeVisible({ + timeout: 20_000, + }); await expect( - page.getByTestId("query-builder").getByRole("searchbox"), - ).toHaveValue("alpha"); + page.getByTestId("chart-options").locator("select").first(), + ).toHaveValue("bar"); }); }); diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts @@ -108,19 +108,28 @@ function makeStat( commentCount: Math.round(viewCount / 50), channelFollowerCount: 12345, categories: ["Entertainment"], + tags: ["news"], language: "en", isLivestream: false, + mediaType: "video" as const, + status: "available" as const, hasTranscript: true, cueCount: 10, }; } // Three videos across three months so time-binned charts have >1 category. +// Distinct mediaType/status/tags so those breakdowns have >1 bucket. export function statsPage() { return [ makeStat(VIDEO_TRANSCRIPT_ONLY, "20260101", 1000, 300), - makeStat(VIDEO_CHAT_SMALL, "20260201", 2000, 600), - makeStat(VIDEO_CHAT_LARGE, "20260301", 3000, 1200), + { ...makeStat(VIDEO_CHAT_SMALL, "20260201", 2000, 600), mediaType: "livestream" as const }, + { + ...makeStat(VIDEO_CHAT_LARGE, "20260301", 3000, 1200), + mediaType: "short" as const, + status: "deleted" as const, + tags: ["news", "gaming"], + }, ]; } @@ -221,19 +230,25 @@ export function transcriptPage() { { ...makeSummary(VIDEO_TRANSCRIPT_ONLY, "Transcript only — no chat"), duration: 300, - description: "", + // "zebra" appears only here, and only in the description (not in any + // title or transcript) so a description-scope search isolates it. + description: "Filmed on location with a zebra in the background.", + tags: ["news"], cues: transcriptCues("transcript-only video"), }, { ...makeSummary(VIDEO_CHAT_SMALL, "Small live chat"), duration: 300, - description: "", + description: "A quiet stream with a small audience.", + tags: ["news"], cues: transcriptCues("small chat video"), }, { ...makeSummary(VIDEO_CHAT_LARGE, "Large live chat"), duration: 3600, - description: "", + description: "A busy stream with a large audience.", + // "gaming" appears only as a tag here — isolates the tags scope. + tags: ["news", "gaming"], cues: transcriptCues("large chat video"), }, ]; diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts @@ -18,7 +18,7 @@ type SLeaf = { k: "l"; i?: string; q: string; - s: "transcripts" | "chat" | "metadata"; + s: "transcripts" | "chat" | "metadata" | "description" | "tags"; r?: 1; h?: 0; n?: 1; @@ -171,6 +171,43 @@ test.describe("composite search — query tree", () => { await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG]); }); + test("description leaf: matches a term that lives only in the description", async ({ + page, + }) => { + // "zebra" appears only in VIDEO_TRANSCRIPT_ONLY's description — not in any + // title or transcript — so this isolates the new description scope. + const tree: SGroup = { + k: "g", + o: "AND", + c: [{ k: "l", q: "zebra", s: "description" }], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG]); + // And a transcript-scope search for the same term finds nothing. + const tTree: SGroup = { + k: "g", + o: "AND", + c: [{ k: "l", q: "zebra", s: "transcripts" }], + }; + await page.goto(`/?qt=${qt(tTree)}`); + await expect(page.getByText("No matching videos.")).toBeVisible({ + timeout: 15_000, + }); + }); + + test("tags leaf: matches a term that lives only in a tag", async ({ + page, + }) => { + // "gaming" is a tag only on VIDEO_CHAT_LARGE — not in any title/transcript. + const tree: SGroup = { + k: "g", + o: "AND", + c: [{ k: "l", q: "gaming", s: "tags" }], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [CHAT_LARGE_SLUG]); + }); + test("legacy URL: ?q=alpha auto-migrates to a single-leaf transcripts query", async ({ page, }) => { diff --git a/export/e2e/search-date-filter.spec.ts b/export/e2e/search-date-filter.spec.ts @@ -197,7 +197,7 @@ test.describe("export search — date-range filter", () => { }); }); -test.describe("export search — date range in share + chart handoff", () => { +test.describe("export search — date range in shared links", () => { test.use({ permissions: ["clipboard-read", "clipboard-write"] }); test.beforeEach(async ({ page, context }) => { @@ -234,24 +234,31 @@ test.describe("export search — date range in share + chart handoff", () => { await expect(card(page, /Report from 2026/)).toHaveCount(0); }); - test("'Chart this search' carries the date range into the chart config", async ({ + test("the date range and chart view both ride along in a shared link", async ({ page, + context, }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); await searchReports(page); await setDates(page, "2025-06-01", "2025-12-31"); - const reqP = page.waitForRequest((r) => r.url().includes("addChart")); - await page.getByRole("button", { name: /Chart this search/ }).click(); + // Switch to the chart view — the chart now visualizes this same search, so + // its date scope is just the committed filter (stats aren't mocked in this + // spec, so the chart itself shows a load notice; we assert the shared state). + await page + .getByTestId("view-toggle") + .getByRole("button", { name: "Chart" }) + .click(); await page - .getByTestId("chart-this-search-menu") - .getByRole("button", { name: "Mentions over time" }) + .getByTestId("profiles-row") + .getByRole("button", { name: "Share current search" }) .click(); - const req = await reqP; - const addChart = new URL(req.url()).searchParams.get("addChart") ?? ""; - const config = JSON.parse(addChart); - // chartShare encodes ChartFilters under `f` with short keys df/dt. - expect(config.f?.df).toBe("20250601"); - expect(config.f?.dt).toBe("20251231"); + const clip = await page.evaluate(() => navigator.clipboard.readText()); + const url = new URL(clip); + expect(url.searchParams.get("fdf")).toBe("20250601"); + expect(url.searchParams.get("fdt")).toBe("20251231"); + expect(url.searchParams.get("view")).toBe("chart"); + expect(url.searchParams.get("cs")).toBeTruthy(); }); }); diff --git a/export/e2e/share-current-search.spec.ts b/export/e2e/share-current-search.spec.ts @@ -69,6 +69,9 @@ test.describe("search page — share current search", () => { expect(url.searchParams.get("ch")).toBeNull(); expect(url.searchParams.get("nol")).toBeNull(); expect(url.searchParams.get("nd")).toBeNull(); + // Sharing from the Results view carries no chart-view params. + expect(url.searchParams.get("view")).toBeNull(); + expect(url.searchParams.get("cs")).toBeNull(); }); test("round-trips a v1 share link, then strips filter params on next commit", async ({