Archilyzer · Source

archilyzer

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

commit 5b16353f3c2d75b3fc59bec1f1839561c3ceeba3
parent 40349e80dd2522384c2eef4c22bd0293b8c59ecf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 26 May 2026 20:42:48 -0400

charts

Diffstat:
M.gitignore | 3+++
Acommon/bin/build-chart-templates.ts | 10++++++++++
Acommon/bin/build-stats.ts | 8++++++++
Acommon/components.json | 20++++++++++++++++++++
Acommon/components/charts/ChartCard.tsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/ChartConfigEditor.tsx | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/ChartView.tsx | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/ChartsDashboard.tsx | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/FilterBar.tsx | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/chartExport.ts | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/useSearchSeries.ts | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/statsCache.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/ui/chart.tsx | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/buildStats.ts | 0
Acommon/lib/chartAggregate.ts | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/chartConfig.ts | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/chartShare.ts | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/chartsStore.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/paths.ts | 6++++++
Acommon/lib/stats.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/transcripts-server.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/utils.ts | 6++++++
Mcommon/package.json | 6+++++-
Mcommon/tsconfig.json | 6+++++-
Meditor/CHANGELOG.md | 1+
Meditor/app/build/buildAction.ts | 16++++++++++++++++
Meditor/app/build/components/BuildButtons.tsx | 31+++++++++++++++++++++++++++++++
Meditor/app/build/page.tsx | 1+
Aeditor/app/charts/EditorChartsClient.tsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/charts/actions.ts | 21+++++++++++++++++++++
Aeditor/app/charts/page.tsx | 28++++++++++++++++++++++++++++
Aeditor/app/exported/[...path]/route.ts | 35+++++++++++++++++++++++++++++++++++
Meditor/app/globals.css | 26++++++++++++++++++++++++++
Meditor/app/layout.tsx | 1+
Meditor/next.config.ts | 10++++++++++
Mexport/CHANGELOG.md | 3+++
Aexport/app/charts/ChartsClient.tsx | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/charts/page.tsx | 19+++++++++++++++++++
Mexport/app/globals.css | 26++++++++++++++++++++++++++
Mexport/app/layout.tsx | 8+++++++-
Aexport/e2e/charts.spec.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/fixtures/data.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/helpers.ts | 18++++++++++++++++++
Mexport/package.json | 4+++-
Mpnpm-lock.yaml | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
45 files changed, 3367 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -53,6 +53,8 @@ yarn-error.log* /export/public/subs/ /export/public/summaries/ /export/public/transcripts/ +/export/public/stats/ +/export/public/chart-templates.json /transcripts/index.mdb/ # editor e2e fixtures and ephemeral state @@ -71,3 +73,4 @@ yarn-error.log* # site config /settings.json +/chart-templates.json diff --git a/common/bin/build-chart-templates.ts b/common/bin/build-chart-templates.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env tsx +// Bakes the editor-authored chart templates into the static export's public +// dir as chart-templates.json. Falls back to built-in presets when no +// templates file has been authored. +import { getPaths } from "../lib/paths"; +import { syncTemplatesToExport, exportTemplatesPath } from "../lib/chartsStore"; + +const paths = getPaths(); +syncTemplatesToExport(paths); +console.log(`Wrote ${exportTemplatesPath(paths)}`); diff --git a/common/bin/build-stats.ts b/common/bin/build-stats.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env tsx +import { getPaths } from "../lib/paths"; +import { buildStats } from "../controller/buildStats"; + +buildStats({ paths: getPaths() }).catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/common/components.json b/common/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "../export/app/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/components/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/common/components/charts/ChartCard.tsx b/common/components/charts/ChartCard.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import type { DisplaySummary } from "../../lib/transcripts"; +import type { VideoStat } from "../../lib/stats"; +import type { ChartConfig, ChartFilters } from "../../lib/chartConfig"; +import { aggregate, mergeFilters } from "../../lib/chartAggregate"; +import { ChartView } from "./ChartView"; +import { useSearchSeries } from "./useSearchSeries"; +import { copyPngToClipboard, exportCsv, exportPng } from "./chartExport"; + +type Props = { + config: ChartConfig; + stats: readonly VideoStat[]; + summaries: DisplaySummary[]; + dashboardFilters: ChartFilters; + onEdit?: () => void; + onRemove?: () => void; +}; + +const btn = + "rounded-md border border-zinc-200 dark:border-zinc-700 px-2 py-1 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50"; + +export function ChartCard({ + config, + stats, + summaries, + dashboardFilters, + onEdit, + onRemove, +}: Props) { + const ref = useRef<HTMLDivElement>(null); + const [busy, setBusy] = useState<string | null>(null); + + const merged = useMemo<ChartConfig>( + () => ({ ...config, filters: mergeFilters(dashboardFilters, config.filters) }), + [config, dashboardFilters], + ); + + // Both data sources are hooked unconditionally (rules of hooks); the search + // hook is inert unless this chart has a search block. + const metaData = useMemo( + () => (config.search ? null : aggregate(stats, merged)), + [stats, merged, config.search], + ); + const searchState = useSearchSeries( + merged, + config.search ?? { queryTree: "", metric: "matchedVideos" }, + stats, + summaries, + !!config.search, + ); + + const data = metaData ?? searchState.data; + const loading = config.search ? searchState.loading : false; + + const run = async (label: string, fn: () => void | Promise<void>) => { + setBusy(label); + try { + await fn(); + } catch (e) { + console.error(e); + alert(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(null); + } + }; + + return ( + <div + ref={ref} + className="flex flex-col gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="min-w-0"> + <h3 className="truncate text-sm font-semibold">{config.title}</h3> + {config.search && ( + <p className="text-xs text-zinc-500"> + {loading + ? "Searching…" + : `${searchState.matchedVideos} videos · ${searchState.totalHits} hits`} + </p> + )} + </div> + <div className="flex shrink-0 gap-1"> + {onEdit && ( + <button className={btn} onClick={onEdit} type="button"> + Edit + </button> + )} + <button + className={btn} + type="button" + disabled={busy !== null} + onClick={() => run("png", () => exportPng(ref.current, config.title))} + > + PNG + </button> + <button + className={btn} + type="button" + disabled={busy !== null} + onClick={() => run("copy", () => copyPngToClipboard(ref.current))} + > + Copy + </button> + <button + className={btn} + type="button" + disabled={busy !== null} + onClick={() => run("csv", () => exportCsv(data, config.title))} + > + CSV + </button> + {onRemove && ( + <button + className={`${btn} text-red-600 dark:text-red-400`} + onClick={onRemove} + type="button" + > + Remove + </button> + )} + </div> + </div> + <ChartView data={data} config={merged} /> + </div> + ); +} diff --git a/common/components/charts/ChartConfigEditor.tsx b/common/components/charts/ChartConfigEditor.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState } from "react"; +import type { + ChartConfig, + ChartType, + MetricField, + SeriesGroupBy, + TimeBin, + XAxisField, + YMetric, +} from "../../lib/chartConfig"; +import { + newGroup, + newLeaf, + parseRoot, + stringifyRoot, + forEachLeaf, + type LayerScope, +} from "../../lib/searchQuery"; + +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" }, +]; + +// Pull the first leaf's query/scope out of a serialized tree for editing. +function firstLeaf(tree?: string): { query: string; scope: LayerScope } { + if (!tree) return { query: "", scope: "transcripts" }; + const root = parseRoot(tree); + let found = { query: "", scope: "transcripts" as LayerScope }; + if (root) + forEachLeaf(root, (l) => { + if (!found.query && l.query) found = { query: l.query, scope: l.scope }; + }); + return found; +} + +export function ChartConfigEditor({ + initial, + onSave, + onCancel, +}: { + initial: ChartConfig; + onSave: (c: ChartConfig) => void; + onCancel: () => void; +}) { + const [draft, setDraft] = useState<ChartConfig>(initial); + const seed = firstLeaf(initial.search?.queryTree); + const [searchOn, setSearchOn] = useState(!!initial.search); + const [term, setTerm] = useState(seed.query); + const [scope, setScope] = useState<LayerScope>(seed.scope); + + const patch = (p: Partial<ChartConfig>) => setDraft((d) => ({ ...d, ...p })); + const patchX = (x: XAxisField) => patch({ x }); + const patchY = (y: YMetric) => patch({ y }); + + const save = () => { + let search = draft.search; + if (searchOn && term.trim()) { + const root = newGroup({ children: [newLeaf({ query: term.trim(), scope })] }); + search = { + queryTree: stringifyRoot(root), + metric: draft.search?.metric ?? "matchedVideos", + }; + } else { + search = undefined; + } + onSave({ ...draft, search }); + }; + + return ( + <div className="flex flex-col gap-4 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 p-4"> + <label className={lbl}> + Title + <input + className={sel} + value={draft.title} + onChange={(e) => patch({ title: e.target.value })} + /> + </label> + + <div className="flex flex-wrap gap-4"> + <label className={lbl}> + Chart type + <select + className={sel} + value={draft.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={draft.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> + </select> + </label> + + <label className="flex items-center gap-2 self-end text-sm"> + <input + type="checkbox" + checked={draft.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={draft.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> + {draft.x.kind === "time" && ( + <label className={lbl}> + Bin + <select + className={sel} + value={draft.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> + )} + {draft.x.kind === "category" && ( + <label className={lbl}> + Field + <select + className={sel} + value={draft.x.field} + onChange={(e) => + patchX({ + kind: "category", + field: e.target.value as "channel" | "platform" | "language" | "category", + }) + } + > + <option value="channel">Channel</option> + <option value="platform">Platform</option> + <option value="language">Language</option> + <option value="category">Category</option> + </select> + </label> + )} + {draft.x.kind === "numericBucket" && ( + <label className={lbl}> + Bucket (minutes) + <input + type="number" + min={1} + className={`${sel} w-24`} + value={Math.round(draft.x.bucketSize / 60)} + onChange={(e) => + patchX({ + kind: "numericBucket", + field: "duration", + bucketSize: Math.max(1, Number(e.target.value)) * 60, + }) + } + /> + </label> + )} + </fieldset> + + <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={draft.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: draft.y.agg === "count" ? "viewCount" : draft.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> + {draft.y.agg !== "count" && ( + <label className={lbl}> + Of + <select + className={sel} + value={draft.y.field} + onChange={(e) => + patchY({ + agg: (draft.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> + + <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">Search-derived (optional)</legend> + <label className="flex items-center gap-2 self-end text-sm"> + <input + type="checkbox" + checked={searchOn} + onChange={(e) => setSearchOn(e.target.checked)} + /> + Plot search matches + </label> + {searchOn && ( + <> + <label className={lbl}> + Term + <input + className={sel} + value={term} + placeholder="e.g. bitcoin" + onChange={(e) => setTerm(e.target.value)} + /> + </label> + <label className={lbl}> + In + <select + className={sel} + value={scope} + onChange={(e) => setScope(e.target.value as LayerScope)} + > + <option value="transcripts">Transcripts</option> + <option value="metadata">Title / channel</option> + <option value="chat">Live chat</option> + </select> + </label> + <label className={lbl}> + Plot + <select + className={sel} + value={draft.search?.metric ?? "matchedVideos"} + onChange={(e) => + patch({ + search: { + queryTree: draft.search?.queryTree ?? "", + metric: e.target.value as "matchedVideos" | "totalHits", + }, + }) + } + > + <option value="matchedVideos">Matched videos</option> + <option value="totalHits">Total hits</option> + </select> + </label> + <p className="w-full text-xs text-zinc-500"> + Y metric is ignored for search charts; each matched video counts + once (or by hit count). + </p> + </> + )} + </fieldset> + + <div className="flex gap-2"> + <button + type="button" + onClick={save} + className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700" + > + Save + </button> + <button + type="button" + onClick={onCancel} + 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" + > + Cancel + </button> + </div> + </div> + ); +} diff --git a/common/components/charts/ChartView.tsx b/common/components/charts/ChartView.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useMemo } from "react"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Line, + LineChart, + Pie, + PieChart, + XAxis, + YAxis, +} from "recharts"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig as ShadcnChartConfig, +} from "../ui/chart"; +import type { ChartData } from "../../lib/chartAggregate"; +import type { ChartConfig } from "../../lib/chartConfig"; + +// Safe CSS-var ids per series (channel names contain spaces/punctuation that +// can't appear in `--color-<key>`), while keeping the human label for legends. +function seriesMeta(data: ChartData) { + return data.series.map((s, i) => ({ + id: `s${i}`, + label: s.key, + color: `var(--chart-${(i % 5) + 1})`, + })); +} + +export function ChartView({ + data, + config, +}: { + data: ChartData; + config: ChartConfig; +}) { + const meta = useMemo(() => seriesMeta(data), [data]); + + const shadcnConfig = useMemo<ShadcnChartConfig>(() => { + const c: ShadcnChartConfig = {}; + for (const m of meta) c[m.id] = { label: m.label, color: m.color }; + return c; + }, [meta]); + + const rows = useMemo(() => { + return data.categories.map((label, i) => { + const row: Record<string, string | number> = { x: label }; + data.series.forEach((s, si) => { + row[meta[si].id] = s.points[i]?.y ?? 0; + }); + return row; + }); + }, [data, meta]); + + if (data.categories.length === 0) { + return ( + <div className="flex h-[260px] items-center justify-center text-sm text-zinc-500"> + No data for this configuration. + </div> + ); + } + + const showLegend = meta.length > 1; + + if (config.type === "pie") { + const first = data.series[0]; + const pieData = data.categories.map((label, i) => ({ + name: label, + value: first?.points[i]?.y ?? 0, + })); + const pieConfig: ShadcnChartConfig = {}; + data.categories.forEach((label, i) => { + pieConfig[`p${i}`] = { label, color: `var(--chart-${(i % 5) + 1})` }; + }); + return ( + <ChartContainer config={pieConfig} className="h-[260px] w-full"> + <PieChart> + <ChartTooltip content={<ChartTooltipContent />} /> + <Pie data={pieData} dataKey="value" nameKey="name" innerRadius={40}> + {pieData.map((_, i) => ( + <Cell key={i} fill={`var(--chart-${(i % 5) + 1})`} /> + ))} + </Pie> + </PieChart> + </ChartContainer> + ); + } + + const common = ( + <> + <CartesianGrid vertical={false} strokeDasharray="3 3" /> + <XAxis + dataKey="x" + tickLine={false} + axisLine={false} + tickMargin={8} + minTickGap={16} + /> + <YAxis tickLine={false} axisLine={false} width={40} /> + <ChartTooltip content={<ChartTooltipContent />} /> + {showLegend && <ChartLegend content={<ChartLegendContent />} />} + </> + ); + + return ( + <ChartContainer config={shadcnConfig} className="h-[260px] w-full"> + {config.type === "line" ? ( + <LineChart data={rows} accessibilityLayer> + {common} + {meta.map((m) => ( + <Line + key={m.id} + dataKey={m.id} + name={m.label} + type="monotone" + stroke={`var(--color-${m.id})`} + strokeWidth={2} + dot={false} + /> + ))} + </LineChart> + ) : config.type === "area" ? ( + <AreaChart data={rows} accessibilityLayer> + {common} + {meta.map((m) => ( + <Area + key={m.id} + dataKey={m.id} + name={m.label} + type="monotone" + stroke={`var(--color-${m.id})`} + fill={`var(--color-${m.id})`} + fillOpacity={0.2} + stackId={meta.length > 1 ? "a" : undefined} + /> + ))} + </AreaChart> + ) : ( + <BarChart data={rows} accessibilityLayer> + {common} + {meta.map((m) => ( + <Bar + key={m.id} + dataKey={m.id} + name={m.label} + fill={`var(--color-${m.id})`} + radius={2} + stackId={config.type === "stackedBar" ? "a" : undefined} + /> + ))} + </BarChart> + )} + </ChartContainer> + ); +} diff --git a/common/components/charts/ChartsDashboard.tsx b/common/components/charts/ChartsDashboard.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { DisplaySummary } from "../../lib/transcripts"; +import type { VideoStat } from "../../lib/stats"; +import { + newChart, + type ChartConfig, + type ChartFilters, + type Dashboard, + type GalleryTemplate, +} from "../../lib/chartConfig"; +import { stringifyDashboard, parseDashboard } from "../../lib/chartShare"; +import { ChartCard } from "./ChartCard"; +import { ChartConfigEditor } from "./ChartConfigEditor"; +import { FilterBar, type ChannelOption } from "./FilterBar"; + +type Props = { + initialDashboard: Dashboard; + gallery: GalleryTemplate[]; + stats: readonly VideoStat[]; + summaries: DisplaySummary[]; + channels: ChannelOption[]; + // Viewer: reflect the board in the URL for shareable links. + syncUrl?: boolean; + // Editor: persist authored templates (debounced). + onPersist?: (dashboard: Dashboard) => void; +}; + +const primaryBtn = + "rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"; +const ghostBtn = + "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"; + +export function ChartsDashboard({ + initialDashboard, + gallery, + stats, + summaries, + channels, + syncUrl, + onPersist, +}: Props) { + const [dashboard, setDashboard] = useState<Dashboard>(initialDashboard); + const [editing, setEditing] = useState<ChartConfig | null>(null); + const [showGallery, setShowGallery] = useState(false); + const [shareCopied, setShareCopied] = useState(false); + const hydrated = useRef(false); + + // Restore a shared board from the URL once on mount (viewer only). + useEffect(() => { + if (!syncUrl || hydrated.current) return; + hydrated.current = true; + const params = new URLSearchParams(window.location.search); + const raw = params.get("dash"); + if (raw) { + const parsed = parseDashboard(raw); + if (parsed && parsed.charts.length) setDashboard(parsed); + } + }, [syncUrl]); + + // Reflect the board into ?dash= as it changes (viewer only). + useEffect(() => { + if (!syncUrl || !hydrated.current) return; + const params = new URLSearchParams(window.location.search); + params.set("dash", stringifyDashboard(dashboard)); + const next = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState(window.history.state, "", next); + }, [dashboard, syncUrl]); + + // Persist authored templates (editor only), debounced. + const persistRef = useRef(onPersist); + persistRef.current = onPersist; + useEffect(() => { + if (!persistRef.current) return; + const t = setTimeout(() => persistRef.current?.(dashboard), 600); + return () => clearTimeout(t); + }, [dashboard]); + + const setFilters = useCallback((filters: ChartFilters) => { + setDashboard((d) => ({ ...d, filters })); + }, []); + + const upsertChart = (cfg: ChartConfig) => { + setDashboard((d) => { + const idx = d.charts.findIndex((c) => c.id === cfg.id); + const charts = + idx >= 0 + ? d.charts.map((c) => (c.id === cfg.id ? cfg : c)) + : [...d.charts, cfg]; + return { ...d, charts }; + }); + setEditing(null); + }; + + const removeChart = (id: string) => + setDashboard((d) => ({ ...d, charts: d.charts.filter((c) => c.id !== id) })); + + const addFromGallery = (t: GalleryTemplate) => { + setDashboard((d) => ({ + ...d, + charts: [...d.charts, { ...t.config, id: newChart().id }], + })); + setShowGallery(false); + }; + + const share = async () => { + const params = new URLSearchParams(); + params.set("dash", stringifyDashboard(dashboard)); + const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; + try { + await navigator.clipboard.writeText(url); + setShareCopied(true); + setTimeout(() => setShareCopied(false), 1800); + } catch { + window.prompt("Copy this link:", url); + } + }; + + return ( + <div className="flex flex-col gap-4"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <h2 className="text-lg font-semibold">{dashboard.title}</h2> + <div className="flex gap-2"> + <button className={ghostBtn} onClick={() => setShowGallery((v) => !v)} type="button"> + Add chart + </button> + <button + className={ghostBtn} + type="button" + onClick={() => setEditing(newChart())} + > + New blank + </button> + <button className={primaryBtn} onClick={share} type="button"> + {shareCopied ? "Link copied!" : "Share"} + </button> + </div> + </div> + + <FilterBar filters={dashboard.filters} channels={channels} onChange={setFilters} /> + + {showGallery && ( + <div className="grid gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 p-3 sm:grid-cols-2 lg:grid-cols-3"> + {gallery.map((t) => ( + <button + key={t.id} + type="button" + onClick={() => addFromGallery(t)} + className="flex flex-col gap-1 rounded-md border border-zinc-200 dark:border-zinc-700 p-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-800" + > + <span className="text-sm font-medium">{t.title}</span> + {t.description && ( + <span className="text-xs text-zinc-500">{t.description}</span> + )} + </button> + ))} + </div> + )} + + {editing && ( + <ChartConfigEditor + initial={editing} + onSave={upsertChart} + onCancel={() => setEditing(null)} + /> + )} + + <div className="grid gap-4 lg:grid-cols-2"> + {dashboard.charts.map((cfg) => ( + <ChartCard + key={cfg.id} + config={cfg} + stats={stats} + summaries={summaries} + dashboardFilters={dashboard.filters} + onEdit={() => setEditing(cfg)} + onRemove={() => removeChart(cfg.id)} + /> + ))} + </div> + + {dashboard.charts.length === 0 && ( + <p className="text-sm text-zinc-500"> + No charts yet — use “Add chart” to start from a template or “New + blank” to build one. + </p> + )} + </div> + ); +} diff --git a/common/components/charts/FilterBar.tsx b/common/components/charts/FilterBar.tsx @@ -0,0 +1,130 @@ +"use client"; + +import type { Platform } from "../../lib/platform"; +import type { ChartFilters } from "../../lib/chartConfig"; + +export type ChannelOption = { slug: string; name: string }; + +const PLATFORMS: Platform[] = ["youtube", "rumble", "odysee"]; + +// "20260513" <-> "2026-05-13" for <input type="date">. +function ymdToInput(ymd?: string): string { + if (!ymd || !/^\d{8}$/.test(ymd)) return ""; + return `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`; +} +function inputToYmd(v: string): string | undefined { + const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/); + return m ? `${m[1]}${m[2]}${m[3]}` : undefined; +} + +const field = "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"; +const label = "text-xs font-medium text-zinc-500"; + +export function FilterBar({ + filters, + channels, + onChange, +}: { + filters: ChartFilters; + channels: ChannelOption[]; + onChange: (next: ChartFilters) => void; +}) { + const set = (patch: Partial<ChartFilters>) => onChange({ ...filters, ...patch }); + + const toggleChannel = (slug: string) => { + const cur = new Set(filters.channels ?? []); + if (cur.has(slug)) cur.delete(slug); + else cur.add(slug); + set({ channels: cur.size ? Array.from(cur) : undefined }); + }; + const togglePlatform = (p: Platform) => { + const cur = new Set(filters.platforms ?? []); + if (cur.has(p)) cur.delete(p); + else cur.add(p); + set({ platforms: cur.size ? Array.from(cur) : undefined }); + }; + + return ( + <div className="flex flex-wrap items-end gap-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-3"> + <label className="flex flex-col gap-1"> + <span className={label}>From</span> + <input + type="date" + className={field} + value={ymdToInput(filters.dateFrom)} + onChange={(e) => set({ dateFrom: inputToYmd(e.target.value) })} + /> + </label> + <label className="flex flex-col gap-1"> + <span className={label}>To</span> + <input + type="date" + className={field} + value={ymdToInput(filters.dateTo)} + onChange={(e) => set({ dateTo: inputToYmd(e.target.value) })} + /> + </label> + + <div className="flex flex-col gap-1"> + <span className={label}>Platform</span> + <div className="flex gap-1"> + {PLATFORMS.map((p) => { + const on = filters.platforms?.includes(p) ?? false; + return ( + <button + key={p} + type="button" + onClick={() => togglePlatform(p)} + className={`rounded-md border px-2 py-1 text-xs capitalize ${ + on + ? "border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-200" + : "border-zinc-300 dark:border-zinc-700" + }`} + > + {p} + </button> + ); + })} + </div> + </div> + + <label className="flex items-center gap-2 self-center pt-4 text-sm"> + <input + type="checkbox" + checked={filters.hasTranscriptOnly ?? false} + onChange={(e) => + set({ hasTranscriptOnly: e.target.checked || undefined }) + } + /> + Has transcript only + </label> + + {channels.length > 0 && ( + <details className="w-full"> + <summary className={`${label} cursor-pointer`}> + Channels{filters.channels?.length ? ` (${filters.channels.length})` : ""} + </summary> + <div className="mt-2 flex flex-wrap gap-1"> + {channels.map((c) => { + const on = filters.channels?.includes(c.slug) ?? false; + return ( + <button + key={c.slug} + type="button" + onClick={() => toggleChannel(c.slug)} + className={`rounded-md border px-2 py-1 text-xs ${ + on + ? "border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-200" + : "border-zinc-300 dark:border-zinc-700" + }`} + > + {c.name} + </button> + ); + })} + </div> + </details> + )} + </div> + ); +} diff --git a/common/components/charts/chartExport.ts b/common/components/charts/chartExport.ts @@ -0,0 +1,129 @@ +"use client"; + +import type { ChartData } from "../../lib/chartAggregate"; +import { toCsv } from "../../lib/chartAggregate"; + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function exportCsv(data: ChartData, filename: string): void { + downloadBlob( + new Blob([toCsv(data)], { type: "text/csv;charset=utf-8" }), + filename.endsWith(".csv") ? filename : `${filename}.csv`, + ); +} + +// Recharts renders an <svg>, but its colors/fonts come from CSS vars + Tailwind +// classes that don't travel with a serialized SVG. Inline the computed paint +// properties onto a clone so the rasterized PNG matches what's on screen. +const PAINT_PROPS = [ + "fill", + "stroke", + "stroke-width", + "stroke-dasharray", + "color", + "font-family", + "font-size", + "font-weight", + "opacity", + "text-anchor", +] as const; + +function inlineStyles(src: Element, dst: Element): void { + const cs = getComputedStyle(src as HTMLElement); + let style = ""; + for (const p of PAINT_PROPS) { + const v = cs.getPropertyValue(p); + if (v) style += `${p}:${v};`; + } + dst.setAttribute("style", style); + const srcChildren = src.children; + const dstChildren = dst.children; + for (let i = 0; i < srcChildren.length; i++) { + inlineStyles(srcChildren[i], dstChildren[i]); + } +} + +async function svgToPngBlob( + svg: SVGSVGElement, + scale = 2, + background = "#ffffff", +): Promise<Blob> { + const rect = svg.getBoundingClientRect(); + const width = rect.width || svg.clientWidth || 640; + const height = rect.height || svg.clientHeight || 360; + + const clone = svg.cloneNode(true) as SVGSVGElement; + inlineStyles(svg, clone); + clone.setAttribute("width", String(width)); + clone.setAttribute("height", String(height)); + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + + const svgString = new XMLSerializer().serializeToString(clone); + const svgUrl = + "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgString); + + const img = new Image(); + await new Promise<void>((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Failed to rasterize chart SVG")); + img.src = svgUrl; + }); + + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(width * scale); + canvas.height = Math.ceil(height * scale); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas 2D context unavailable"); + ctx.fillStyle = background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + return await new Promise<Blob>((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), + "image/png", + ); + }); +} + +// Find the recharts <svg> inside a chart card container. +export function findChartSvg(container: HTMLElement | null): SVGSVGElement | null { + return (container?.querySelector(".recharts-surface") ?? + container?.querySelector("svg")) as SVGSVGElement | null; +} + +export async function exportPng( + container: HTMLElement | null, + filename: string, +): Promise<void> { + const svg = findChartSvg(container); + if (!svg) throw new Error("No chart to export"); + const blob = await svgToPngBlob(svg); + downloadBlob(blob, filename.endsWith(".png") ? filename : `${filename}.png`); +} + +export async function copyPngToClipboard( + container: HTMLElement | null, +): Promise<void> { + const svg = findChartSvg(container); + if (!svg) throw new Error("No chart to copy"); + const blob = await svgToPngBlob(svg); + const ClipboardItemCtor = ( + globalThis as { ClipboardItem?: typeof ClipboardItem } + ).ClipboardItem; + if (!ClipboardItemCtor || !navigator.clipboard?.write) { + throw new Error("Clipboard image copy not supported in this browser"); + } + await navigator.clipboard.write([ + new ClipboardItemCtor({ "image/png": blob }), + ]); +} diff --git a/common/components/charts/useSearchSeries.ts b/common/components/charts/useSearchSeries.ts @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { DisplaySummary } from "../../lib/transcripts"; +import type { VideoStat } from "../../lib/stats"; +import type { ChartConfig, SearchSource } from "../../lib/chartConfig"; +import { + xLabelsForStat, + seriesKeyForStat, + type ChartData, + type ChartSeries, +} from "../../lib/chartAggregate"; +import { applyFilters } from "../../lib/chartAggregate"; +import { parseRoot } from "../../lib/searchQuery"; +import { runQueryTree, type TreeProgress } from "../../lib/searchEval"; + +export type SearchSeriesState = { + data: ChartData; + loading: boolean; + matchedVideos: number; + totalHits: number; + error: string | null; +}; + +const EMPTY: ChartData = { categories: [], series: [] }; + +// Runs the viewer search engine for a chart's `search` block and bins the +// matched videos by the chart's x-axis. Reuses the exact binning/grouping +// helpers as metadata charts so the two look identical. +export function useSearchSeries( + config: ChartConfig, + search: SearchSource, + stats: readonly VideoStat[], + summaries: DisplaySummary[], + enabled: boolean, +): SearchSeriesState { + const [progress, setProgress] = useState<TreeProgress | null>(null); + const [error, setError] = useState<string | null>(null); + const controllerRef = useRef<{ cancel(): void } | null>(null); + + const root = useMemo(() => parseRoot(search.queryTree), [search.queryTree]); + + useEffect(() => { + setProgress(null); + setError(null); + controllerRef.current?.cancel(); + controllerRef.current = null; + if (!enabled || !root || summaries.length === 0) return; + try { + const controller = runQueryTree({ + root, + globalScope: summaries.map((s) => s.slug), + summaries, + initialHitLimit: 50000, + concurrency: 8, + flushIntervalMs: 150, + emit: (p) => setProgress(p), + }); + controllerRef.current = controller; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + return () => { + controllerRef.current?.cancel(); + controllerRef.current = null; + }; + }, [enabled, root, summaries]); + + const statBySlug = useMemo(() => { + const m = new Map<string, VideoStat>(); + for (const s of stats) m.set(s.slug, s); + return m; + }, [stats]); + + const result = useMemo<SearchSeriesState>(() => { + if (!enabled || !root) { + return { data: EMPTY, loading: false, matchedVideos: 0, totalHits: 0, error }; + } + if (!progress) { + return { data: EMPTY, loading: true, matchedVideos: 0, totalHits: 0, error }; + } + // seriesKey -> xLabel -> { value, sort } + const buckets = new Map< + string, + Map<string, { value: number; sort: number | 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 }); + } + } + + 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), + ); + + return { + data: { categories, series }, + loading: !progress.done, + matchedVideos, + totalHits, + error, + }; + }, [enabled, root, progress, statBySlug, config, search.metric, error]); + + return result; +} diff --git a/common/components/statsCache.ts b/common/components/statsCache.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useMemo } from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import type { StatsManifest, VideoStat } from "../lib/stats"; +import { statsPageFileName } from "../lib/stats"; + +async function fetchJson<T>(url: string): Promise<T> { + const r = await fetch(url); + if (!r.ok) throw new Error(`Failed to fetch ${url}: ${r.status}`); + return (await r.json()) as T; +} + +export type StatsState = { + manifest: StatsManifest | null; + stats: VideoStat[]; + loadedPages: number; + pageCount: number; + ready: boolean; + error: Error | null; +}; + +export function useStatsManifest() { + return useQuery<StatsManifest>({ + queryKey: ["stats-manifest"], + queryFn: () => fetchJson<StatsManifest>("/stats/manifest.json"), + }); +} + +// Mirrors summariesCache.useSummaries: load the manifest, then fan out the +// byte-capped page files via react-query and concatenate them. +export function useStats(): StatsState { + const manifestQuery = useStatsManifest(); + const manifest = manifestQuery.data ?? null; + const pageCount = manifest?.pageCount ?? 0; + + const pageQueries = useQueries({ + queries: Array.from({ length: pageCount }, (_, i) => ({ + queryKey: ["stats-page", i], + queryFn: () => fetchJson<VideoStat[]>(`/stats/${statsPageFileName(i)}`), + enabled: pageCount > 0, + })), + }); + + const loadedPages = pageQueries.filter((q) => q.data).length; + const ready = pageCount > 0 && loadedPages === pageCount; + + const stats = useMemo<VideoStat[]>(() => { + if (loadedPages === 0) return []; + const out: VideoStat[] = []; + for (const q of pageQueries) if (q.data) out.push(...q.data); + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadedPages, ready]); + + const error = + manifestQuery.error instanceof Error + ? manifestQuery.error + : ((pageQueries.find((q) => q.error)?.error as Error | undefined) ?? null); + + return { manifest, stats, loadedPages, pageCount, ready, error }; +} diff --git a/common/components/ui/chart.tsx b/common/components/ui/chart.tsx @@ -0,0 +1,369 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "../../lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext<ChartContextProps | null>(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a <ChartContainer />") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + ref={ref} + className={cn( + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + className + )} + {...props} + > + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer> + {children} + </RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map( + ([theme, prefix]) => ` +${prefix} [data-chart=${id}] { +${colorConfig + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color + return color ? ` --color-${key}: ${color};` : null + }) + .join("\n")} +} +` + ) + .join("\n"), + }} + /> + ) +} + +const ChartTooltip = RechartsPrimitive.Tooltip + +const ChartTooltipContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<typeof RechartsPrimitive.Tooltip> & + React.ComponentProps<"div"> & { + hideLabel?: boolean + hideIndicator?: boolean + indicator?: "line" | "dot" | "dashed" + nameKey?: string + labelKey?: string + } +>( + ( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref + ) => { + const { config } = useChart() + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null + } + + const [item] = payload + const key = `${labelKey || item?.dataKey || item?.name || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const value = + !labelKey && typeof label === "string" + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label + + if (labelFormatter) { + return ( + <div className={cn("font-medium", labelClassName)}> + {labelFormatter(value, payload)} + </div> + ) + } + + if (!value) { + return null + } + + return <div className={cn("font-medium", labelClassName)}>{value}</div> + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]) + + if (!active || !payload?.length) { + return null + } + + const nestLabel = payload.length === 1 && indicator !== "dot" + + return ( + <div + ref={ref} + className={cn( + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )} + > + {!nestLabel ? tooltipLabel : null} + <div className="grid gap-1.5"> + {payload + .filter((item) => item.type !== "none") + .map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const indicatorColor = color || item.payload.fill || item.color + + return ( + <div + key={item.dataKey} + className={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn( + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", + { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + } + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} + <div + className={cn( + "flex flex-1 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )} + > + <div className="grid gap-1.5"> + {nestLabel ? tooltipLabel : null} + <span className="text-muted-foreground"> + {itemConfig?.label || item.name} + </span> + </div> + {item.value && ( + <span className="font-mono font-medium tabular-nums text-foreground"> + {item.value.toLocaleString()} + </span> + )} + </div> + </> + )} + </div> + ) + })} + </div> + </div> + ) + } +) +ChartTooltipContent.displayName = "ChartTooltip" + +const ChartLegend = RechartsPrimitive.Legend + +const ChartLegendContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { + hideIcon?: boolean + nameKey?: string + } +>( + ( + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, + ref + ) => { + const { config } = useChart() + + if (!payload?.length) { + return null + } + + return ( + <div + ref={ref} + className={cn( + "flex items-center justify-center gap-4", + verticalAlign === "top" ? "pb-3" : "pt-3", + className + )} + > + {payload + .filter((item) => item.type !== "none") + .map((item) => { + const key = `${nameKey || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + + return ( + <div + key={item.value} + className={cn( + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" + )} + > + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className="h-2 w-2 shrink-0 rounded-[2px]" + style={{ + backgroundColor: item.color, + }} + /> + )} + {itemConfig?.label} + </div> + ) + })} + </div> + ) + } +) +ChartLegendContent.displayName = "ChartLegend" + +// Helper to extract item config from a payload. +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string +) { + if (typeof payload !== "object" || payload === null) { + return undefined + } + + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined + + let configLabelKey: string = key + + if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { + configLabelKey = payload[key as keyof typeof payload] as string + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string + } + + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config] +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} 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 @@ -0,0 +1,281 @@ +// Pure, dependency-free aggregation from VideoStat[] → chart-ready series. +// Shared by the chart renderers and the CSV export so what you see equals what +// you download. Search-derived series are produced separately (they need async +// search eval) but emit the same ChartData shape. + +import type { VideoStat } from "./stats"; +import type { + ChartConfig, + ChartFilters, + SeriesGroupBy, + TimeBin, + XAxisField, + YMetric, +} from "./chartConfig"; + +export type ChartPoint = { x: string; y: number }; +export type ChartSeries = { key: string; points: ChartPoint[] }; +export type ChartData = { + // Ordered x-axis category labels shared by every series. + categories: string[]; + series: ChartSeries[]; +}; + +const SERIES_ALL = "all"; +const EMPTY = "(none)"; + +// ─── Time binning ─── + +function parseYmd(ymd: string): { y: number; m: number; d: number } | null { + if (!/^\d{8}$/.test(ymd)) return null; + return { + y: Number(ymd.slice(0, 4)), + m: Number(ymd.slice(4, 6)), + d: Number(ymd.slice(6, 8)), + }; +} + +function isoWeekLabel(y: number, m: number, d: number): string { + // ISO-8601 week date. Thursday-of-week determines the week-year. + const date = new Date(Date.UTC(y, m - 1, d)); + const day = date.getUTCDay() || 7; // Sun=0 → 7 + date.setUTCDate(date.getUTCDate() + 4 - day); + const weekYear = date.getUTCFullYear(); + const yearStart = Date.UTC(weekYear, 0, 1); + const week = Math.ceil(((date.getTime() - yearStart) / 86400000 + 1) / 7); + return `${weekYear}-W${String(week).padStart(2, "0")}`; +} + +export function binTime(ymd: string, bin: TimeBin): string | null { + const p = parseYmd(ymd); + if (!p) return null; + switch (bin) { + case "year": + return String(p.y); + case "quarter": + return `${p.y}-Q${Math.floor((p.m - 1) / 3) + 1}`; + case "month": + return `${p.y}-${String(p.m).padStart(2, "0")}`; + case "week": + return isoWeekLabel(p.y, p.m, p.d); + } +} + +// ─── Filtering ─── + +export function applyFilters( + stats: readonly VideoStat[], + f: ChartFilters, +): VideoStat[] { + const channelSet = f.channels && f.channels.length ? new Set(f.channels) : null; + const platformSet = + f.platforms && f.platforms.length ? new Set(f.platforms) : null; + return stats.filter((s) => { + if (f.dateFrom && s.uploadDate < f.dateFrom) return false; + if (f.dateTo && s.uploadDate > f.dateTo) return false; + if (channelSet && !channelSet.has(s.channelSlug)) return false; + if (platformSet && !platformSet.has(s.platform)) return false; + if (f.hasTranscriptOnly && !s.hasTranscript) return false; + return true; + }); +} + +// Merge a dashboard-level filter with a per-chart filter. Per-chart wins for +// scalars; channel/platform allowlists intersect. +export function mergeFilters(base: ChartFilters, over: ChartFilters): ChartFilters { + const out: ChartFilters = { ...base, ...over }; + if (base.channels?.length && over.channels?.length) { + const o = new Set(over.channels); + out.channels = base.channels.filter((c) => o.has(c)); + } + if (base.platforms?.length && over.platforms?.length) { + const o = new Set(over.platforms); + out.platforms = base.platforms.filter((p) => o.has(p)); + } + out.hasTranscriptOnly = base.hasTranscriptOnly || over.hasTranscriptOnly; + return out; +} + +// ─── Axis + metric extraction ─── + +function metricValue(stat: VideoStat, y: YMetric): number | null { + if (y.agg === "count") return 1; + const v = stat[y.field]; + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +// X labels a stat contributes to. Usually one; the "category" field can be +// many (a video may have several categories) — it then counts in each. +// Exported so search-derived series share identical binning/labelling. +export function xLabelsForStat( + stat: VideoStat, + x: XAxisField, +): { label: string; sort: number | string }[] { + switch (x.kind) { + case "time": { + const label = binTime(stat.uploadDate, x.bin); + return label ? [{ label, sort: label }] : []; + } + case "numericBucket": { + const raw = stat[x.field]; + if (typeof raw !== "number" || !Number.isFinite(raw)) return []; + const size = Math.max(1, x.bucketSize); + const start = Math.floor(raw / size) * size; + return [{ label: bucketLabel(start, size, x.field), sort: start }]; + } + case "category": { + if (x.field === "category") { + 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; + return [{ label: v, sort: v }]; + } + } +} + +function bucketLabel(start: number, size: number, field: string): string { + if (field === "duration") { + const a = Math.round(start / 60); + const b = Math.round((start + size) / 60); + return `${a}–${b}m`; + } + return `${start}–${start + size}`; +} + +export function seriesKeyForStat(stat: VideoStat, groupBy: SeriesGroupBy): string { + switch (groupBy) { + case "channel": + return stat.channel || stat.channelSlug; + case "platform": + return stat.platform; + case "language": + return stat.language || EMPTY; + case "none": + return SERIES_ALL; + } +} + +function reduce(values: number[], y: YMetric): number { + if (y.agg === "count") return values.length; + if (values.length === 0) return 0; + switch (y.agg) { + case "sum": + return values.reduce((a, b) => a + b, 0); + case "avg": + return values.reduce((a, b) => a + b, 0) / values.length; + case "max": + return Math.max(...values); + case "min": + return Math.min(...values); + case "median": { + const s = [...values].sort((a, b) => a - b); + const mid = s.length >> 1; + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; + } + } +} + +// ─── Aggregation ─── + +export function aggregate( + stats: readonly VideoStat[], + config: ChartConfig, +): ChartData { + const filtered = applyFilters(stats, config.filters); + + // seriesKey -> xLabel -> { values, sort } + const buckets = new Map< + string, + Map<string, { values: number[]; sort: number | string }> + >(); + for (const stat of filtered) { + const sKey = seriesKeyForStat(stat, config.groupBy); + const mv = metricValue(stat, config.y); + if (config.y.agg !== "count" && mv === null) continue; // skip missing metric + let xMap = buckets.get(sKey); + if (!xMap) { + xMap = new Map(); + buckets.set(sKey, xMap); + } + for (const { label, sort } of xLabelsForStat(stat, config.x)) { + let cell = xMap.get(label); + if (!cell) { + cell = { values: [], sort }; + xMap.set(label, cell); + } + if (mv !== null) cell.values.push(mv); + } + } + + // Ordered category labels (union across series). + const sortByLabel = new Map<string, number | string>(); + for (const xMap of buckets.values()) { + for (const [label, cell] of xMap) sortByLabel.set(label, cell.sort); + } + let categories = Array.from(sortByLabel.keys()); + + if (config.x.kind === "time" || config.x.kind === "numericBucket") { + categories.sort((a, b) => compareSort(sortByLabel.get(a)!, sortByLabel.get(b)!)); + } else { + // category axis → rank by total value descending (top-N style) + const total = (label: string) => { + let t = 0; + for (const xMap of buckets.values()) { + const cell = xMap.get(label); + if (cell) t += reduce(cell.values, config.y); + } + return t; + }; + categories.sort((a, b) => total(b) - total(a)); + } + + const series: ChartSeries[] = []; + for (const [sKey, xMap] of buckets) { + let running = 0; + const points: ChartPoint[] = categories.map((label) => { + const cell = xMap.get(label); + const y = cell ? reduce(cell.values, config.y) : 0; + if (config.cumulative) { + running += y; + return { x: label, y: running }; + } + return { x: label, y }; + }); + series.push({ key: sKey, points }); + } + // Stable series order: largest total first. + series.sort((a, b) => seriesTotal(b) - seriesTotal(a)); + + return { categories, series }; +} + +function seriesTotal(s: ChartSeries): number { + return s.points.reduce((acc, p) => acc + p.y, 0); +} + +function compareSort(a: number | string, b: number | string): number { + if (typeof a === "number" && typeof b === "number") return a - b; + return String(a).localeCompare(String(b)); +} + +// ─── CSV export ─── + +export function toCsv(data: ChartData): string { + const head = ["x", ...data.series.map((s) => s.key)]; + const rows = [head.map(csvCell).join(",")]; + data.categories.forEach((label, i) => { + const row = [label, ...data.series.map((s) => String(s.points[i]?.y ?? 0))]; + rows.push(row.map(csvCell).join(",")); + }); + return rows.join("\n"); +} + +function csvCell(v: string): string { + return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; +} diff --git a/common/lib/chartConfig.ts b/common/lib/chartConfig.ts @@ -0,0 +1,193 @@ +// Chart + dashboard configuration model. Pure types + factories shared by the +// viewer (renders/edits charts), the editor (authors default templates), and +// chartShare.ts (URL serialization). Field names in ChartConfig are NOT +// abbreviated here — chartShare.ts handles the compact URL form separately. + +import type { Platform } from "./platform"; + +export type TimeBin = "week" | "month" | "quarter" | "year"; + +export type CategoryField = "channel" | "platform" | "language" | "category"; +export type NumericBucketField = "duration"; + +export type XAxisField = + | { kind: "time"; bin: TimeBin } + | { kind: "category"; field: CategoryField } + | { kind: "numericBucket"; field: NumericBucketField; bucketSize: number }; + +export type MetricAgg = "count" | "sum" | "avg" | "median" | "max" | "min"; +export type MetricField = + | "duration" + | "viewCount" + | "likeCount" + | "commentCount" + | "channelFollowerCount" + | "cueCount"; + +export type YMetric = + | { agg: "count" } + | { agg: Exclude<MetricAgg, "count">; field: MetricField }; + +export type ChartType = "line" | "bar" | "area" | "stackedBar" | "pie"; +export type SeriesGroupBy = "none" | "channel" | "platform" | "language"; + +export type ChartFilters = { + dateFrom?: string; // YYYYMMDD inclusive + dateTo?: string; // YYYYMMDD inclusive + channels?: string[]; // channelSlug allowlist + platforms?: Platform[]; + hasTranscriptOnly?: boolean; +}; + +// When present, the chart plots search results instead of pure metadata. The +// query tree is the serialized form from searchQuery.ts (stringifyRoot), so it +// round-trips through the existing search infrastructure. +export type SearchSource = { + queryTree: string; + metric: "matchedVideos" | "totalHits"; +}; + +export type ChartConfig = { + id: string; + title: string; + type: ChartType; + x: XAxisField; + y: YMetric; + groupBy: SeriesGroupBy; + cumulative: boolean; + filters: ChartFilters; + search?: SearchSource; +}; + +export type Dashboard = { + version: 1; + title: string; + filters: ChartFilters; + charts: ChartConfig[]; +}; + +export type GalleryTemplate = { + id: string; + title: string; + description?: string; + config: ChartConfig; +}; + +export type ChartTemplates = { + version: 1; + defaultDashboard: Dashboard; + gallery: GalleryTemplate[]; +}; + +// Deterministic id counter (no Date.now/Math.random) so server + client render +// matching ids and Next's hydration check stays quiet — same rationale as +// searchQuery.ts nextId. +let _idCounter = 0; +export function nextChartId(prefix = "c"): string { + _idCounter = (_idCounter + 1) | 0; + return `${prefix}${_idCounter.toString(36)}`; +} + +export function newChart(partial: Partial<ChartConfig> = {}): ChartConfig { + return { + id: partial.id ?? nextChartId(), + title: partial.title ?? "Untitled chart", + type: partial.type ?? "line", + x: partial.x ?? { kind: "time", bin: "month" }, + y: partial.y ?? { agg: "count" }, + groupBy: partial.groupBy ?? "none", + cumulative: partial.cumulative ?? false, + filters: partial.filters ?? {}, + search: partial.search, + }; +} + +// ─── Presets ─── +// Code-level fallbacks used when no editor-authored chart-templates.json is +// present. Also seed the editor's authoring UI. + +export const PRESETS: GalleryTemplate[] = [ + { + id: "preset-library-growth", + title: "Library growth over time", + description: "Cumulative video count by upload month.", + config: newChart({ + id: "preset-library-growth", + title: "Library growth over time", + type: "area", + x: { kind: "time", bin: "month" }, + y: { agg: "count" }, + cumulative: true, + }), + }, + { + id: "preset-uploads-per-month", + title: "Uploads per month", + description: "Number of videos uploaded each month.", + config: newChart({ + id: "preset-uploads-per-month", + title: "Uploads per month", + type: "bar", + x: { kind: "time", bin: "month" }, + y: { agg: "count" }, + }), + }, + { + id: "preset-duration-distribution", + title: "Duration distribution", + description: "Video count bucketed by length (5-minute buckets).", + config: newChart({ + id: "preset-duration-distribution", + title: "Duration distribution", + type: "bar", + x: { kind: "numericBucket", field: "duration", bucketSize: 300 }, + y: { agg: "count" }, + }), + }, + { + id: "preset-top-channels", + title: "Top channels by video count", + description: "Which channels have the most videos.", + config: newChart({ + id: "preset-top-channels", + title: "Top channels by video count", + type: "bar", + x: { kind: "category", field: "channel" }, + y: { agg: "count" }, + }), + }, + { + id: "preset-top-channels-views", + title: "Top channels by total views", + description: "Channels ranked by summed view count.", + config: newChart({ + id: "preset-top-channels-views", + title: "Top channels by total views", + type: "bar", + x: { kind: "category", field: "channel" }, + y: { agg: "sum", field: "viewCount" }, + }), + }, +]; + +export function defaultDashboard(): Dashboard { + return { + version: 1, + title: "Overview", + filters: {}, + charts: [ + PRESETS[0].config, + PRESETS[1].config, + PRESETS[2].config, + PRESETS[3].config, + ], + }; +} + +export function defaultTemplates(): ChartTemplates { + return { + version: 1, + defaultDashboard: defaultDashboard(), + gallery: PRESETS, + }; +} diff --git a/common/lib/chartShare.ts b/common/lib/chartShare.ts @@ -0,0 +1,169 @@ +// Compact serialization of charts/dashboards for shareable URLs, mirroring +// searchQuery.ts: short keys, ids dropped (regenerated on parse), enums +// validated on the way back in with safe fallbacks. The encoded string is +// stored verbatim in a URL param (?chart= for one chart, ?dash= for a board). + +import type { Platform } from "./platform"; +import { + newChart, + nextChartId, + type ChartConfig, + type ChartFilters, + type ChartType, + type Dashboard, + type SeriesGroupBy, + type TimeBin, + type XAxisField, + type YMetric, +} from "./chartConfig"; + +const TYPES: ChartType[] = ["line", "bar", "area", "stackedBar", "pie"]; +const BINS: TimeBin[] = ["week", "month", "quarter", "year"]; +const GROUPS: SeriesGroupBy[] = ["none", "channel", "platform", "language"]; +const PLATFORMS: Platform[] = ["youtube", "rumble", "odysee"]; + +type SC = Record<string, unknown>; + +function str(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} +function oneOf<T extends string>(v: unknown, allowed: T[], fallback: T): T { + return typeof v === "string" && (allowed as string[]).includes(v) + ? (v as T) + : fallback; +} + +// ─── Filters ─── +function encFilters(f: ChartFilters): SC | undefined { + const out: SC = {}; + if (f.dateFrom) out.df = f.dateFrom; + if (f.dateTo) out.dt = f.dateTo; + if (f.channels?.length) out.ch = f.channels; + if (f.platforms?.length) out.pl = f.platforms; + if (f.hasTranscriptOnly) out.ht = 1; + return Object.keys(out).length ? out : undefined; +} +function decFilters(raw: unknown): ChartFilters { + if (!raw || typeof raw !== "object") return {}; + const r = raw as SC; + const out: ChartFilters = {}; + if (str(r.df)) out.dateFrom = str(r.df); + if (str(r.dt)) out.dateTo = str(r.dt); + if (Array.isArray(r.ch)) out.channels = r.ch.filter((c): c is string => typeof c === "string"); + if (Array.isArray(r.pl)) { + out.platforms = r.pl.filter((p): p is Platform => + (PLATFORMS as string[]).includes(p as string), + ); + } + if (r.ht === 1) out.hasTranscriptOnly = true; + return out; +} + +// ─── X axis ─── +function encX(x: XAxisField): SC { + if (x.kind === "time") return { k: "t", b: x.bin }; + if (x.kind === "numericBucket") return { k: "n", f: x.field, s: x.bucketSize }; + return { k: "c", f: x.field }; +} +function decX(raw: unknown): XAxisField { + const r = (raw && typeof raw === "object" ? raw : {}) as SC; + if (r.k === "n") { + return { + kind: "numericBucket", + field: "duration", + bucketSize: typeof r.s === "number" && r.s > 0 ? r.s : 300, + }; + } + if (r.k === "c") { + return { + kind: "category", + field: oneOf(r.f, ["channel", "platform", "language", "category"], "channel"), + }; + } + return { kind: "time", bin: oneOf(r.b, BINS, "month") }; +} + +// ─── Y metric ─── +function encY(y: YMetric): SC { + return y.agg === "count" ? { a: "count" } : { a: y.agg, f: y.field }; +} +function decY(raw: unknown): YMetric { + const r = (raw && typeof raw === "object" ? raw : {}) as SC; + if (r.a === "count" || r.a === undefined) return { agg: "count" }; + const agg = oneOf(r.a, ["sum", "avg", "median", "max", "min"], "sum"); + const field = oneOf( + r.f, + ["duration", "viewCount", "likeCount", "commentCount", "channelFollowerCount", "cueCount"], + "viewCount", + ); + return { agg, field }; +} + +// ─── Chart ─── +function encChart(c: ChartConfig): SC { + const out: SC = { t: c.title, ty: c.type, x: encX(c.x), y: encY(c.y) }; + if (c.groupBy !== "none") out.g = c.groupBy; + if (c.cumulative) out.cu = 1; + const f = encFilters(c.filters); + if (f) out.f = f; + if (c.search) out.s = { q: c.search.queryTree, m: c.search.metric }; + return out; +} +function decChart(raw: unknown): ChartConfig { + const r = (raw && typeof raw === "object" ? raw : {}) as SC; + const search = + r.s && typeof r.s === "object" && str((r.s as SC).q) + ? { + queryTree: str((r.s as SC).q)!, + metric: oneOf((r.s as SC).m, ["matchedVideos", "totalHits"], "matchedVideos"), + } + : undefined; + return newChart({ + id: nextChartId(), + title: str(r.t) ?? "Untitled chart", + type: oneOf(r.ty, TYPES, "line"), + x: decX(r.x), + y: decY(r.y), + groupBy: oneOf(r.g, GROUPS, "none"), + cumulative: r.cu === 1, + filters: decFilters(r.f), + search, + }); +} + +// ─── Public API ─── + +export function stringifyChart(c: ChartConfig): string { + return JSON.stringify(encChart(c)); +} +export function parseChart(s: string): ChartConfig | null { + try { + return decChart(JSON.parse(s)); + } catch { + return null; + } +} + +export function stringifyDashboard(d: Dashboard): string { + const out: SC = { v: 1, t: d.title, c: d.charts.map(encChart) }; + const f = encFilters(d.filters); + if (f) out.f = f; + return JSON.stringify(out); +} +export function parseDashboard(s: string): Dashboard | null { + let raw: unknown; + try { + raw = JSON.parse(s); + } catch { + return null; + } + if (!raw || typeof raw !== "object") return null; + const r = raw as SC; + const charts = Array.isArray(r.c) ? r.c.map(decChart) : []; + return { + version: 1, + title: str(r.t) ?? "Shared dashboard", + filters: decFilters(r.f), + charts, + }; +} diff --git a/common/lib/chartsStore.ts b/common/lib/chartsStore.ts @@ -0,0 +1,59 @@ +// Server-side persistence for editor-authored chart templates. The canonical +// file is getPaths().chartsConfigFile; the export build copies it into +// exportPublicDir as chart-templates.json so the static viewer can fetch it. + +import path from "node:path"; +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import type { Paths } from "./paths"; +import { + defaultTemplates, + type ChartTemplates, + type Dashboard, +} from "./chartConfig"; + +const EXPORT_TEMPLATES_FILE = "chart-templates.json"; + +export function exportTemplatesPath(paths: Paths): string { + return path.join(paths.exportPublicDir, EXPORT_TEMPLATES_FILE); +} + +function coerce(raw: unknown): ChartTemplates { + if (!raw || typeof raw !== "object") return defaultTemplates(); + const r = raw as Partial<ChartTemplates>; + const base = defaultTemplates(); + return { + version: 1, + defaultDashboard: + r.defaultDashboard && Array.isArray(r.defaultDashboard.charts) + ? (r.defaultDashboard as Dashboard) + : base.defaultDashboard, + gallery: Array.isArray(r.gallery) ? r.gallery : base.gallery, + }; +} + +export function readTemplates(paths: Paths): ChartTemplates { + try { + return coerce(JSON.parse(readFileSync(paths.chartsConfigFile, "utf8"))); + } catch { + return defaultTemplates(); + } +} + +function writeJsonAtomic(filePath: string, value: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + const tmp = `${filePath}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(value, null, 2)); + renameSync(tmp, filePath); +} + +export function writeTemplates(paths: Paths, templates: ChartTemplates): void { + writeJsonAtomic(paths.chartsConfigFile, templates); +} + +// Copy the canonical templates into the served export dir. Called by the +// export build (build:templates) and by the editor on save so the dev viewer +// reflects changes immediately. +export function syncTemplatesToExport(paths: Paths): void { + const templates = readTemplates(paths); + writeJsonAtomic(exportTemplatesPath(paths), templates); +} diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -13,7 +13,9 @@ export type Paths = { exportSummariesDir: string; exportTranscriptsDir: string; exportSubsDir: string; + exportStatsDir: string; settingsFile: string; + chartsConfigFile: string; ytdlpBin: string; whisperBin: string; whisperModel: string; @@ -42,8 +44,12 @@ export function getPaths(): Paths { exportSummariesDir: path.join(exportPublicDir, "summaries"), exportTranscriptsDir: path.join(exportPublicDir, "transcripts"), exportSubsDir: path.join(exportPublicDir, "subs"), + exportStatsDir: path.join(exportPublicDir, "stats"), settingsFile: process.env.SETTINGS_FILE ?? path.join(monorepoRoot, "settings.json"), + chartsConfigFile: + process.env.CHARTS_CONFIG_FILE ?? + path.join(monorepoRoot, "chart-templates.json"), ytdlpBin: process.env.YTDLP_BIN ?? "yt-dlp", whisperBin: process.env.WHISPER_BIN ?? "whisper-cli", whisperModel: diff --git a/common/lib/stats.ts b/common/lib/stats.ts @@ -0,0 +1,54 @@ +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 = 1; +export const STATS_MANIFEST_VERSION = 1; + +// 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. +export const STATS_MAX_PAGE_BYTES = 20 * 1024 * 1024; + +export function statsPageFileName(index: number): string { + return `page-${String(index).padStart(4, "0")}.json`; +} + +// One record per video. Engagement fields are nullable: not every platform / +// metadata.info.json carries them, and older archived videos may omit them. +export type VideoStat = { + slug: string; // `${channelSlug}/${id}` + id: string; + channelSlug: string; + channel: string; // display name + platform: Platform; + uploadDate: string; // YYYYMMDD + timestamp: number | null; // unix seconds + duration: number; // seconds + viewCount: number | null; + likeCount: number | null; + commentCount: number | null; + channelFollowerCount: number | null; + categories: string[]; + language: string | null; + isLivestream: boolean; + hasTranscript: boolean; + cueCount: number | null; +}; + +export type StatsChannelEntry = { + slug: string; + name: string; + count: number; +}; + +export type StatsManifest = { + version: number; + generatedAt: string; + totalCount: number; + pageCount: number; + // Records the byte cap pages were written under, so a changed cap invalidates + // stale pages (mirrors the transcript index's ChannelTranscriptsManifest). + maxPageBytes: number; + channels: StatsChannelEntry[]; +}; diff --git a/common/lib/transcripts-server.ts b/common/lib/transcripts-server.ts @@ -1,5 +1,6 @@ import { formatDate, formatDuration } from "./format"; import type { DisplaySummary, Platform, TranscriptSummary } from "./transcripts"; +import type { VideoStat } from "./stats"; export type RawMetadata = { id?: string; @@ -17,6 +18,15 @@ export type RawMetadata = { extractor_key?: string; webpage_url?: string; webpage_url_basename?: string; + // Engagement / categorical fields used by the charts feature. Present in + // yt-dlp's metadata.info.json but not all platforms/older archives carry them. + timestamp?: number; + view_count?: number; + like_count?: number; + comment_count?: number; + channel_follower_count?: number; + categories?: string[]; + language?: string; }; function detectPlatform(meta: RawMetadata): Platform { @@ -67,6 +77,45 @@ export function summarize( }; } +function numOrNull(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +// Builds the chart stats record for a video. Reuses summarize() for the shared +// platform/id/livestream logic, then layers on the engagement/categorical +// fields. Transcript presence + cue count come from the caller (the index +// LMDB), so this stays a pure metadata transform. +export function summarizeStats( + channelSlug: string, + videoDir: string, + meta: RawMetadata, + configName: string | undefined, + transcript: { hasTranscript: boolean; cueCount: number | null }, +): VideoStat { + const base = summarize(channelSlug, videoDir, meta, configName); + return { + slug: base.slug, + id: base.id, + channelSlug: base.channelSlug, + channel: base.channel, + platform: base.platform, + uploadDate: base.uploadDate, + timestamp: numOrNull(meta.timestamp), + duration: base.duration, + viewCount: numOrNull(meta.view_count), + likeCount: numOrNull(meta.like_count), + commentCount: numOrNull(meta.comment_count), + channelFollowerCount: numOrNull(meta.channel_follower_count), + categories: Array.isArray(meta.categories) + ? meta.categories.filter((c): c is string => typeof c === "string") + : [], + language: typeof meta.language === "string" ? meta.language : null, + isLivestream: base.isLivestream, + hasTranscript: transcript.hasTranscript, + cueCount: transcript.cueCount, + }; +} + export function toDisplaySummary( t: TranscriptSummary, extras: { isDeleted?: boolean; isUnlisted?: boolean } = {}, diff --git a/common/lib/utils.ts b/common/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/common/package.json b/common/package.json @@ -7,12 +7,16 @@ "@sindresorhus/slugify": "^3.0.0", "@tanstack/react-query": "^5.99.1", "@tanstack/react-virtual": "^3.13.0", + "clsx": "^2.1.1", "execa": "^9.6.1", "fs-extra": "^11.3.4", "lmdb": "^3.5.4", + "lucide-react": "^1.16.0", "markdown-to-jsx": "^7.7.4", "p-limit": "^7.3.0", - "react-player": "^2.16.1" + "react-player": "^2.16.1", + "recharts": "2.15.4", + "tailwind-merge": "^3.6.0" }, "peerDependencies": { "next": "16.2.3", diff --git a/common/tsconfig.json b/common/tsconfig.json @@ -2,7 +2,11 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "jsx": "preserve", - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Charts authoring + stats dataset.** A new **Charts** tab lets you author the default chart dashboard that ships to the viewer — add/edit/remove charts and configure axes, metrics, series, filters, and search-derived series with a live preview; edits save automatically and bake into the next export build. A new **Build stats dataset** action on the Build page extracts per-video stats (views, likes, comments, follower count, duration, categories, language, cue counts) into `export/public/stats/` for the charts to read; it's incremental (mtime short-circuit) and also runs automatically as part of the static export build. - **Per-operation progress bars on `/jobs/active`.** Each running download/transcription now shows its own live progress bar parsed from the tool's shell output — yt-dlp's download percent (and fragment count) and whisper's transcribed position against the audio length. Batch jobs (Transcribe missing, Download from playlist, Sync, …) list a bar per in-flight video underneath the batch's overall bar; standalone single-video jobs get one too. The screen now polls about once a second so the bars advance live. - **"Drain" (soft-cancel) for batch jobs.** Alongside the existing Cancel (which immediately kills everything), running batches now offer **Drain**: it lets the in-flight operations finish, starts no new ones, then completes the job normally and releases the queue so the next batch can run. Hard Cancel still works at any time, including mid-drain. - **"Build static export" moved from the Build page to the top of the Deploy page**, co-locating it with the export changelog preview, "Cut release", and "Deploy static export" so the whole build → review → cut → deploy sequence lives on one page. diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex"; +import { buildStats } from "yt-dlp-transcript-common/controller/buildStats"; import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll"; import { normalizeAllLiveChat } from "yt-dlp-transcript-common/controller/normalizeAllLiveChat"; import { @@ -38,6 +39,21 @@ export async function buildIndexAction( return result; } +export async function buildStatsAction( + queueKey?: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "build-stats", + queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(), + paths, + fn: async (onLog, signal) => { + await buildStats({ paths, onLog, signal }); + revalidatePath("/charts"); + }, + }); +} + export async function buildExportAction( queueKey?: string, ): Promise<StreamActionResult> { diff --git a/editor/app/build/components/BuildButtons.tsx b/editor/app/build/components/BuildButtons.tsx @@ -14,6 +14,7 @@ import { archiveLiveChatAction, archiveTranscriptsAction, buildIndexAction, + buildStatsAction, normalizeLiveChatAction, normalizeTranscriptsAction, } from "../buildAction"; @@ -27,6 +28,7 @@ const DEFAULT_BUILD_QUEUE = "build"; export function BuildButtons({ existingQueues }: Props) { const [indexQueue, setIndexQueue] = useState(DEFAULT_BUILD_QUEUE); + const [statsQueue, setStatsQueue] = useState(DEFAULT_BUILD_QUEUE); const [normalizeQueue, setNormalizeQueue] = useState(DEFAULT_BUILD_QUEUE); const [archiveQueue, setArchiveQueue] = useState(DEFAULT_BUILD_QUEUE); const [combinedQueue, setCombinedQueue] = useState(DEFAULT_BUILD_QUEUE); @@ -80,6 +82,35 @@ export function BuildButtons({ existingQueues }: Props) { <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> <div> + <h2 className="text-lg font-semibold">Build stats dataset</h2> + <p className="text-sm text-zinc-500"> + Extracts per-video stats (views, likes, comments, duration, + categories, …) into <code>export/public/stats/</code> for the viewer + charts. Reads engagement metrics from each{" "} + <code>metadata.info.json</code>; cue counts come from the search + index, so run <strong>Build index</strong> first. Incremental + (mtime short-circuit). + </p> + </div> + <StreamActionLog + trigger={() => buildStatsAction(statsQueue)} + cancelAction={cancelJobAction} + buttonLabel="Build stats dataset" + runningLabel="Building stats dataset…" + extraControls={ + <QueueControl + value={statsQueue} + onChange={setStatsQueue} + defaultQueueKey={DEFAULT_BUILD_QUEUE} + existingQueues={existingQueues} + actionLabel="Build stats dataset" + /> + } + /> + </section> + + <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> + <div> <h2 className="text-lg font-semibold">Normalize transcripts</h2> <p className="text-sm text-zinc-500"> Walks every video and writes{" "} diff --git a/editor/app/build/page.tsx b/editor/app/build/page.tsx @@ -9,6 +9,7 @@ export const metadata: Metadata = { title: "Build" }; const BUILD_KINDS = new Set([ "build-index", + "build-stats", "build-export", "normalize-transcripts", "archive-transcripts", diff --git a/editor/app/charts/EditorChartsClient.tsx b/editor/app/charts/EditorChartsClient.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback } from "react"; +import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider"; +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 type { ChartTemplates, Dashboard } from "yt-dlp-transcript-common/lib/chartConfig"; +import { saveDefaultDashboardAction } from "./actions"; + +function Inner({ templates }: { templates: ChartTemplates }) { + const { stats, ready, loadedPages, pageCount, manifest, error } = useStats(); + const { summaries } = useSummaries(); + + const onPersist = useCallback((dashboard: Dashboard) => { + void saveDefaultDashboardAction(dashboard); + }, []); + + if (error) { + return ( + <p className="text-sm text-red-600 dark:text-red-400"> + Failed to load the stats dataset ({error.message}). Run{" "} + <strong>Build index</strong> then <strong>Build stats dataset</strong>{" "} + on the Build page first. + </p> + ); + } + if (!ready) { + 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={templates.gallery} + stats={stats} + summaries={summaries} + channels={channels} + onPersist={onPersist} + /> + ); +} + +export function EditorChartsClient({ templates }: { templates: ChartTemplates }) { + return ( + <QueryProvider> + <Inner templates={templates} /> + </QueryProvider> + ); +} diff --git a/editor/app/charts/actions.ts b/editor/app/charts/actions.ts @@ -0,0 +1,21 @@ +"use server"; + +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + readTemplates, + writeTemplates, + syncTemplatesToExport, +} from "yt-dlp-transcript-common/lib/chartsStore"; +import type { Dashboard } from "yt-dlp-transcript-common/lib/chartConfig"; + +// Persist the authored default dashboard. Keeps the gallery as-is and mirrors +// the result into the served export dir so the static viewer picks it up. +export async function saveDefaultDashboardAction( + dashboard: Dashboard, +): Promise<{ ok: true }> { + const paths = getPaths(); + const templates = readTemplates(paths); + writeTemplates(paths, { ...templates, defaultDashboard: dashboard }); + syncTemplatesToExport(paths); + return { ok: true }; +} diff --git a/editor/app/charts/page.tsx b/editor/app/charts/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { readTemplates } from "yt-dlp-transcript-common/lib/chartsStore"; +import { EditorChartsClient } from "./EditorChartsClient"; + +export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Charts" }; + +export default function ChartsPage() { + const templates = readTemplates(getPaths()); + 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"> + Author the default dashboard shown to viewers. Edits save + automatically and bake into the next export build. Requires the stats + dataset — build it on the{" "} + <a className="underline" href="/build"> + Build + </a>{" "} + page. + </p> + </div> + <EditorChartsClient templates={templates} /> + </div> + ); +} diff --git a/editor/app/exported/[...path]/route.ts b/editor/app/exported/[...path]/route.ts @@ -0,0 +1,35 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; + +export const dynamic = "force-dynamic"; + +// Serves built export artifacts (stats/summaries/transcripts JSON) from +// exportPublicDir so the editor's charts authoring tab can preview real data. +// The static viewer serves these as plain files; the editor proxies them. +export async function GET( + _request: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path: segments } = await params; + // Reject path traversal / absolute escapes. + if (segments.some((s) => s === ".." || s.includes("\0") || path.isAbsolute(s))) { + return new Response("Bad request", { status: 400 }); + } + const root = getPaths().exportPublicDir; + const target = path.join(root, ...segments); + if (!target.startsWith(root + path.sep)) { + return new Response("Bad request", { status: 400 }); + } + try { + const body = await readFile(target); + const type = target.endsWith(".json") + ? "application/json" + : "application/octet-stream"; + return new Response(body, { + headers: { "content-type": type, "cache-control": "no-store" }, + }); + } catch { + return new Response("Not found", { status: 404 }); + } +} diff --git a/editor/app/globals.css b/editor/app/globals.css @@ -4,17 +4,43 @@ :root { --background: #ffffff; --foreground: #171717; + /* Semantic tokens consumed by shadcn/ui chart primitives. */ + --border: #e4e4e7; + --muted: #f4f4f5; + --muted-foreground: #71717a; + /* Chart series palette. */ + --chart-1: #2563eb; + --chart-2: #16a34a; + --chart-3: #ea580c; + --chart-4: #9333ea; + --chart-5: #db2777; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-border: var(--border); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); } @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; --foreground: #ededed; + --border: #27272a; + --muted: #27272a; + --muted-foreground: #a1a1aa; + --chart-1: #60a5fa; + --chart-2: #4ade80; + --chart-3: #fb923c; + --chart-4: #c084fc; + --chart-5: #f472b6; } } diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -35,6 +35,7 @@ const NAV_LINKS: NavLink[] = [ { href: "/jobs", label: "Jobs", badgeKey: "jobs" }, { href: "/jobs/active", label: "Active", badgeKey: "running" }, { href: "/build", label: "Build" }, + { href: "/charts", label: "Charts" }, { href: "/deploy", label: "Deploy" }, { href: "/settings", label: "Settings" }, { href: "/changelog", label: "Changelog", badgeKey: "changelog" }, diff --git a/editor/next.config.ts b/editor/next.config.ts @@ -8,6 +8,16 @@ const nextConfig: NextConfig = { bodySizeLimit: "10mb", }, }, + // Serve the built export artifacts (stats/summaries/transcripts) through a + // route handler so the charts authoring tab can preview real data, mirroring + // the static viewer's served paths. + async rewrites() { + return [ + { source: "/stats/:path*", destination: "/exported/stats/:path*" }, + { source: "/summaries/:path*", destination: "/exported/summaries/:path*" }, + { source: "/transcripts/:path*", destination: "/exported/transcripts/:path*" }, + ]; + }, }; export default nextConfig; diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **Charts.** A new **Charts** tab visualises the library. Build charts from video metadata — upload date (binned by week/month/quarter/year, with optional cumulative running totals), engagement metrics (views, likes, comments, follower count), duration distributions, categories, languages — or from **search matches**, plotting how many videos match a term (or total hits) over time. Pick the chart type (line/bar/stacked/area/pie), split any chart into series by channel/platform/language, and apply a dashboard-wide filter bar (date range, platform, channel, has-transcript). Charts open with an editor-authored default dashboard plus a gallery of templates you can fork and customise. **Share** copies a link that restores the whole board, and every chart can be exported as a PNG (download or copy to clipboard) or its data as CSV. Engagement metrics come from a new stats dataset built alongside the search index. + ## [0.2.2] - 2026-05-26 - **Browse all videos without searching.** With no search query entered, the results list now shows every video matching the current filters (newest-first), instead of a "add a layer to search" prompt. Typing a query narrows the list as before; clearing it returns to the full browse view. This replaces the old workaround of searching regex `.` on titles. - **Collapsing the query builder to one layer no longer strips a hidden setting.** Deselecting "Show hits in results" on a layer and then deleting the other layers used to leave that lone layer permanently hits-off, with no control to fix it (the checkbox is hidden in the single-layer compact view). Collapsing back to compact now resets "Show hits" to on. diff --git a/export/app/charts/ChartsClient.tsx b/export/app/charts/ChartsClient.tsx @@ -0,0 +1,65 @@ +"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, + 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={templates.gallery} + stats={stats} + summaries={summaries} + channels={channels} + syncUrl + /> + ); +} diff --git a/export/app/charts/page.tsx b/export/app/charts/page.tsx @@ -0,0 +1,19 @@ +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/globals.css b/export/app/globals.css @@ -4,11 +4,29 @@ :root { --background: #ffffff; --foreground: #171717; + /* Semantic tokens consumed by shadcn/ui chart primitives. */ + --border: #e4e4e7; + --muted: #f4f4f5; + --muted-foreground: #71717a; + /* Chart series palette. */ + --chart-1: #2563eb; + --chart-2: #16a34a; + --chart-3: #ea580c; + --chart-4: #9333ea; + --chart-5: #db2777; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-border: var(--border); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @@ -17,6 +35,14 @@ :root { --background: #0a0a0a; --foreground: #ededed; + --border: #27272a; + --muted: #27272a; + --muted-foreground: #a1a1aa; + --chart-1: #60a5fa; + --chart-2: #4ade80; + --chart-3: #fb923c; + --chart-4: #c084fc; + --chart-5: #f472b6; } } diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -45,7 +45,13 @@ export default async function RootLayout({ <Link href="/" className="font-semibold tracking-tight"> {settings.headerTitle} </Link> - <nav className="text-sm"> + <nav className="flex items-center gap-4 text-sm"> + <Link + href="/charts" + className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100" + > + Charts + </Link> <Link href="/changelog" className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100" diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } 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. + +test.describe("charts", () => { + test.beforeEach(async ({ page }) => { + await installChartRoutes(page); + }); + + 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); + }); + + 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(); + }); + + 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("search-derived chart counts matched videos", async ({ page }) => { + await page.goto("/charts"); + await page.getByRole("button", { name: "New blank" }).click(); + await page.getByText("Plot search matches").click(); + await page.getByPlaceholder("e.g. bitcoin").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({ + timeout: 20_000, + }); + }); +}); diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts @@ -86,6 +86,105 @@ export function summariesManifest() { }; } +// ─── Charts feature fixtures ─── + +function makeStat( + id: string, + uploadDate: string, + viewCount: number, + duration: number, +) { + return { + slug: slug(id), + id, + channelSlug: CHANNEL_SLUG, + channel: CHANNEL, + platform: "rumble" as const, + uploadDate, + timestamp: Date.parse(`${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}`) / 1000, + duration, + viewCount, + likeCount: Math.round(viewCount / 10), + commentCount: Math.round(viewCount / 50), + channelFollowerCount: 12345, + categories: ["Entertainment"], + language: "en", + isLivestream: false, + hasTranscript: true, + cueCount: 10, + }; +} + +// Three videos across three months so time-binned charts have >1 category. +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), + ]; +} + +export function statsManifest() { + const list = statsPage(); + return { + version: 1, + generatedAt: new Date().toISOString(), + totalCount: list.length, + pageCount: 1, + maxPageBytes: 20 * 1024 * 1024, + channels: [{ slug: CHANNEL_SLUG, name: CHANNEL, count: list.length }], + }; +} + +export function chartTemplates() { + return { + version: 1, + defaultDashboard: { + version: 1, + title: "Overview", + filters: {}, + charts: [ + { + id: "fixture-uploads", + title: "Uploads per month", + type: "bar", + x: { kind: "time", bin: "month" }, + y: { agg: "count" }, + groupBy: "none", + cumulative: false, + filters: {}, + }, + { + id: "fixture-views", + title: "Total views per month", + type: "line", + x: { kind: "time", bin: "month" }, + y: { agg: "sum", field: "viewCount" }, + groupBy: "none", + cumulative: false, + filters: {}, + }, + ], + }, + gallery: [ + { + id: "g-top-channels", + title: "Top channels by video count", + config: { + id: "g-top-channels", + title: "Top channels by video count", + type: "bar", + x: { kind: "category", field: "channel" }, + y: { agg: "count" }, + groupBy: "none", + cumulative: false, + filters: {}, + }, + }, + ], + }; +} + export function transcriptPage() { return [ { diff --git a/export/e2e/helpers.ts b/export/e2e/helpers.ts @@ -2,6 +2,9 @@ import type { Page, Route } from "@playwright/test"; import { channelSubsManifest, channelTranscriptsManifest, + chartTemplates, + statsManifest, + statsPage, subsManifest, subsPage, summaries, @@ -45,6 +48,21 @@ export async function installRoutes(page: Page) { }); } +// Charts page fetches: stats dataset + baked templates. Also installs the +// summaries/transcripts routes so search-derived charts can evaluate. +export async function installChartRoutes(page: Page) { + await installRoutes(page); + await page.route("**/stats/manifest.json", async (route) => { + await fulfillJson(route, statsManifest()); + }); + await page.route("**/stats/page-*.json", async (route) => { + await fulfillJson(route, statsPage()); + }); + await page.route("**/chart-templates.json", async (route) => { + await fulfillJson(route, chartTemplates()); + }); +} + export async function urlParams(page: Page): Promise<URLSearchParams> { const search = await page.evaluate(() => window.location.search); return new URLSearchParams(search); diff --git a/export/package.json b/export/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "next dev", "build:index": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-index.ts", - "prebuild": "pnpm run build:index", + "build:stats": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-stats.ts", + "build:templates": "tsx ../common/bin/build-chart-templates.ts", + "prebuild": "pnpm run build:index && pnpm run build:stats && pnpm run build:templates", "build": "next build", "start": "serve out", "lint": "eslint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.0 version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + clsx: + specifier: ^2.1.1 + version: 2.1.1 execa: specifier: ^9.6.1 version: 9.6.1 @@ -32,6 +35,9 @@ importers: lmdb: specifier: ^3.5.4 version: 3.5.4 + lucide-react: + specifier: ^1.16.0 + version: 1.16.0(react@19.2.4) markdown-to-jsx: specifier: ^7.7.4 version: 7.7.17(react@19.2.4) @@ -50,6 +56,12 @@ importers: react-player: specifier: ^2.16.1 version: 2.16.1(react@19.2.4) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 devDependencies: '@types/fs-extra': specifier: ^11.0.4 @@ -232,6 +244,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -919,6 +935,33 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1295,6 +1338,10 @@ packages: resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1327,6 +1374,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -1367,6 +1458,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1394,6 +1488,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1583,6 +1680,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1594,6 +1694,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -1792,6 +1896,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2084,6 +2192,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2091,6 +2202,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.16.0: + resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2382,15 +2498,40 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-player@2.16.1: resolution: {integrity: sha512-mxP6CqjSWjidtyDoMOSHVPdhX0pY16aSvw5fVr44EMaT7X5Xz46uQ4b/YBm1v2x+3hHkB9PmjEEkmbHb9PXQ4w==} peerDependencies: react: '>=16.6.0' + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2602,6 +2743,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} @@ -2609,6 +2753,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -2704,6 +2851,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + weak-lru-cache@1.2.2: resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==} @@ -2845,6 +2995,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3342,6 +3494,30 @@ snapshots: tslib: 2.8.1 optional: true + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/fs-extra@11.0.4': @@ -3728,6 +3904,8 @@ snapshots: execa: 5.1.1 is-wsl: 2.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3764,6 +3942,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -3796,6 +4012,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -3820,6 +4038,11 @@ snapshots: dependencies: esutils: 2.0.3 + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.7 + csstype: 3.2.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4227,6 +4450,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -4256,6 +4481,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4442,6 +4669,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4711,6 +4940,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.18.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -4719,6 +4950,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.16.0(react@19.2.4): + dependencies: + react: 19.2.4 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4994,6 +5229,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-player@2.16.1(react@19.2.4): dependencies: deepmerge: 4.3.1 @@ -5003,8 +5240,42 @@ snapshots: react: 19.2.4 react-fast-compare: 3.2.2 + react-smooth@4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@babel/runtime': 7.29.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5302,10 +5573,14 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwind-merge@3.6.0: {} + tailwindcss@4.2.4: {} tapable@2.3.3: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -5441,6 +5716,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + weak-lru-cache@1.2.2: {} which-boxed-primitive@1.1.1: