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:
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 ({