commit fe6ed6df139e33fc0cc8cfa9d46c47bf516451d1
parent 70d4085ee8a2f452f6e1919c01820abdded7d032
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 26 May 2026 21:44:11 -0400
charts polish
Diffstat:
9 files changed, 487 insertions(+), 148 deletions(-)
diff --git a/common/components/charts/ChartConfigEditor.tsx b/common/components/charts/ChartConfigEditor.tsx
@@ -54,7 +54,9 @@ export function ChartConfigEditor({
}) {
const [draft, setDraft] = useState<ChartConfig>(initial);
const seed = firstLeaf(initial.search?.queryTree);
- const [searchOn, setSearchOn] = useState(!!initial.search);
+ const [mode, setMode] = useState<"metadata" | "search">(
+ initial.search ? "search" : "metadata",
+ );
const [term, setTerm] = useState(seed.query);
const [scope, setScope] = useState<LayerScope>(seed.scope);
@@ -63,8 +65,10 @@ export function ChartConfigEditor({
const patchY = (y: YMetric) => patch({ y });
const save = () => {
- let search = draft.search;
- if (searchOn && term.trim()) {
+ let search: ChartConfig["search"];
+ if (mode === "search") {
+ // Keep the search block even with an empty term, so the card prompts the
+ // user to fill one in rather than silently reverting to a metadata chart.
const root = newGroup({ children: [newLeaf({ query: term.trim(), scope })] });
search = {
queryTree: stringifyRoot(root),
@@ -127,6 +131,26 @@ export function ChartConfigEditor({
</label>
</div>
+ <div className="flex flex-col gap-1">
+ <span className={lbl}>Data source</span>
+ <div className="inline-flex w-fit overflow-hidden rounded-md border border-zinc-300 dark:border-zinc-700">
+ {(["metadata", "search"] as const).map((m) => (
+ <button
+ key={m}
+ type="button"
+ onClick={() => setMode(m)}
+ className={`px-3 py-1 text-sm capitalize ${
+ mode === m
+ ? "bg-blue-600 text-white"
+ : "bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ }`}
+ >
+ {m === "metadata" ? "Metadata" : "Search matches"}
+ </button>
+ ))}
+ </div>
+ </div>
+
<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}>
@@ -203,6 +227,7 @@ export function ChartConfigEditor({
)}
</fieldset>
+ {mode === "metadata" && (
<fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-zinc-200 dark:border-zinc-800 p-3">
<legend className="px-1 text-xs font-semibold">Y metric</legend>
<label className={lbl}>
@@ -250,65 +275,56 @@ export function ChartConfigEditor({
</label>
)}
</fieldset>
+ )}
+ {mode === "search" && (
<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">
+ <legend className="px-1 text-xs font-semibold">Search</legend>
+ <label className={lbl}>
+ Term
<input
- type="checkbox"
- checked={searchOn}
- onChange={(e) => setSearchOn(e.target.checked)}
+ className={sel}
+ value={term}
+ placeholder="e.g. bitcoin"
+ onChange={(e) => setTerm(e.target.value)}
/>
- 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>
- </>
- )}
+ <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">
+ Each matched video counts once, or by its hit count. The X axis,
+ grouping, and filters above still apply.
+ </p>
</fieldset>
+ )}
<div className="flex gap-2">
<button
diff --git a/common/components/charts/ChartView.tsx b/common/components/charts/ChartView.tsx
@@ -24,14 +24,19 @@ import {
type ChartConfig as ShadcnChartConfig,
} from "../ui/chart";
import type { ChartData } from "../../lib/chartAggregate";
-import type { ChartConfig } from "../../lib/chartConfig";
+import { xAxisLabel, yAxisLabel, type ChartConfig } from "../../lib/chartConfig";
+
+const AXIS_LABEL_STYLE = { fill: "var(--muted-foreground)", fontSize: 11 };
// 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) {
+// An ungrouped chart has a single "all" series — label it by its Y metric
+// (e.g. "Total views") rather than the meaningless internal key.
+function seriesMeta(data: ChartData, config: ChartConfig) {
+ const single = config.groupBy === "none";
return data.series.map((s, i) => ({
id: `s${i}`,
- label: s.key,
+ label: single ? yAxisLabel(config) : s.key,
color: `var(--chart-${(i % 5) + 1})`,
}));
}
@@ -43,7 +48,7 @@ export function ChartView({
data: ChartData;
config: ChartConfig;
}) {
- const meta = useMemo(() => seriesMeta(data), [data]);
+ const meta = useMemo(() => seriesMeta(data, config), [data, config]);
const shadcnConfig = useMemo<ShadcnChartConfig>(() => {
const c: ShadcnChartConfig = {};
@@ -63,7 +68,7 @@ export function ChartView({
if (data.categories.length === 0) {
return (
- <div className="flex h-[260px] items-center justify-center text-sm text-zinc-500">
+ <div className="flex h-[280px] items-center justify-center text-sm text-zinc-500">
No data for this configuration.
</div>
);
@@ -82,7 +87,7 @@ export function ChartView({
pieConfig[`p${i}`] = { label, color: `var(--chart-${(i % 5) + 1})` };
});
return (
- <ChartContainer config={pieConfig} className="h-[260px] w-full">
+ <ChartContainer config={pieConfig} className="h-[280px] w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent />} />
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={40}>
@@ -95,26 +100,49 @@ export function ChartView({
);
}
- 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 />} />}
- </>
- );
+ // Recharts discovers axis/grid/tooltip/legend children by type but does NOT
+ // recurse into React fragments — so these must be passed as a flat array
+ // (which React.Children flattens), never wrapped in <>...</>.
+ const common = [
+ <CartesianGrid key="grid" vertical={false} strokeDasharray="3 3" />,
+ <XAxis
+ key="x"
+ dataKey="x"
+ tickLine={false}
+ axisLine={false}
+ tickMargin={8}
+ minTickGap={16}
+ label={{
+ value: xAxisLabel(config.x),
+ position: "insideBottom",
+ offset: -2,
+ style: AXIS_LABEL_STYLE,
+ }}
+ />,
+ <YAxis
+ key="y"
+ tickLine={false}
+ axisLine={false}
+ width={52}
+ label={{
+ value: yAxisLabel(config),
+ angle: -90,
+ position: "insideLeft",
+ style: { ...AXIS_LABEL_STYLE, textAnchor: "middle" },
+ }}
+ />,
+ <ChartTooltip key="tip" content={<ChartTooltipContent />} />,
+ showLegend ? (
+ <ChartLegend key="leg" content={<ChartLegendContent />} />
+ ) : null,
+ ];
+
+ const margin = { top: 8, right: 12, bottom: 20, left: 8 };
return (
- <ChartContainer config={shadcnConfig} className="h-[260px] w-full">
+ <ChartContainer config={shadcnConfig} className="h-[280px] w-full">
{config.type === "line" ? (
- <LineChart data={rows} accessibilityLayer>
+ <LineChart data={rows} margin={margin} accessibilityLayer>
{common}
{meta.map((m) => (
<Line
@@ -129,7 +157,7 @@ export function ChartView({
))}
</LineChart>
) : config.type === "area" ? (
- <AreaChart data={rows} accessibilityLayer>
+ <AreaChart data={rows} margin={margin} accessibilityLayer>
{common}
{meta.map((m) => (
<Area
@@ -145,7 +173,7 @@ export function ChartView({
))}
</AreaChart>
) : (
- <BarChart data={rows} accessibilityLayer>
+ <BarChart data={rows} margin={margin} accessibilityLayer>
{common}
{meta.map((m) => (
<Bar
diff --git a/common/components/charts/ChartsDashboard.tsx b/common/components/charts/ChartsDashboard.tsx
@@ -8,6 +8,7 @@ import {
type ChartConfig,
type ChartFilters,
type Dashboard,
+ type GalleryGroup,
type GalleryTemplate,
} from "../../lib/chartConfig";
import { stringifyDashboard, parseDashboard } from "../../lib/chartShare";
@@ -17,7 +18,7 @@ import { FilterBar, type ChannelOption } from "./FilterBar";
type Props = {
initialDashboard: Dashboard;
- gallery: GalleryTemplate[];
+ gallery: GalleryGroup[];
stats: readonly VideoStat[];
summaries: DisplaySummary[];
channels: ChannelOption[];
@@ -32,6 +33,8 @@ const primaryBtn =
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";
+const STORAGE_KEY = "charts:dashboard";
+
export function ChartsDashboard({
initialDashboard,
gallery,
@@ -46,26 +49,54 @@ export function ChartsDashboard({
const [showGallery, setShowGallery] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const hydrated = useRef(false);
+ // Set once the user edits the board. Writes (localStorage + ?dash=) only
+ // happen after this, so un-edited visitors keep tracking the latest
+ // editor-authored default and fresh URLs stay clean.
+ const dirty = useRef(false);
+ const markDirty = useCallback(() => {
+ dirty.current = true;
+ }, []);
- // Restore a shared board from the URL once on mount (viewer only).
+ // Restore on mount (viewer only). Precedence: shared link (?dash=) →
+ // localStorage → editor default. Pure restore — does not mark dirty or write.
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);
+ const fromUrl = new URLSearchParams(window.location.search).get("dash");
+ if (fromUrl) {
+ const parsed = parseDashboard(fromUrl);
+ if (parsed && parsed.charts.length) {
+ setDashboard(parsed);
+ return;
+ }
+ }
+ try {
+ const saved = window.localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ const parsed = parseDashboard(saved);
+ if (parsed && parsed.charts.length) setDashboard(parsed);
+ }
+ } catch {
+ // ignore storage errors (private mode, etc.)
}
}, [syncUrl]);
- // Reflect the board into ?dash= as it changes (viewer only).
+ // Persist edits to localStorage + ?dash= (viewer only), once dirty.
useEffect(() => {
- if (!syncUrl || !hydrated.current) return;
+ if (!syncUrl || !hydrated.current || !dirty.current) return;
+ const encoded = stringifyDashboard(dashboard);
+ try {
+ window.localStorage.setItem(STORAGE_KEY, encoded);
+ } catch {
+ // ignore
+ }
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);
+ params.set("dash", encoded);
+ window.history.replaceState(
+ window.history.state,
+ "",
+ `${window.location.pathname}?${params.toString()}`,
+ );
}, [dashboard, syncUrl]);
// Persist authored templates (editor only), debounced.
@@ -77,11 +108,16 @@ export function ChartsDashboard({
return () => clearTimeout(t);
}, [dashboard]);
- const setFilters = useCallback((filters: ChartFilters) => {
- setDashboard((d) => ({ ...d, filters }));
- }, []);
+ const setFilters = useCallback(
+ (filters: ChartFilters) => {
+ markDirty();
+ setDashboard((d) => ({ ...d, filters }));
+ },
+ [markDirty],
+ );
const upsertChart = (cfg: ChartConfig) => {
+ markDirty();
setDashboard((d) => {
const idx = d.charts.findIndex((c) => c.id === cfg.id);
const charts =
@@ -93,15 +129,33 @@ export function ChartsDashboard({
setEditing(null);
};
- const removeChart = (id: string) =>
+ const removeChart = (id: string) => {
+ markDirty();
setDashboard((d) => ({ ...d, charts: d.charts.filter((c) => c.id !== id) }));
+ };
+
+ const resetToDefault = () => {
+ try {
+ window.localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+ if (syncUrl) {
+ window.history.replaceState(window.history.state, "", window.location.pathname);
+ }
+ dirty.current = false;
+ setEditing(null);
+ setDashboard(initialDashboard);
+ };
const addFromGallery = (t: GalleryTemplate) => {
- setDashboard((d) => ({
- ...d,
- charts: [...d.charts, { ...t.config, id: newChart().id }],
- }));
+ markDirty();
+ const cfg: ChartConfig = { ...t.config, id: newChart().id };
+ setDashboard((d) => ({ ...d, charts: [...d.charts, cfg] }));
setShowGallery(false);
+ // Search starters ship with an empty term — open the editor so the user
+ // fills it in immediately.
+ if (cfg.search) setEditing(cfg);
};
const share = async () => {
@@ -132,6 +186,9 @@ export function ChartsDashboard({
>
New blank
</button>
+ <button className={ghostBtn} onClick={resetToDefault} type="button">
+ Reset to default
+ </button>
<button className={primaryBtn} onClick={share} type="button">
{shareCopied ? "Link copied!" : "Share"}
</button>
@@ -141,19 +198,28 @@ export function ChartsDashboard({
<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 className="flex flex-col gap-4 rounded-lg border border-zinc-200 dark:border-zinc-800 p-3">
+ {gallery.map((group) => (
+ <div key={group.id} className="flex flex-col gap-2">
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
+ {group.title}
+ </h3>
+ <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+ {group.templates.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>
+ </div>
))}
</div>
)}
diff --git a/common/lib/chartConfig.ts b/common/lib/chartConfig.ts
@@ -4,6 +4,7 @@
// abbreviated here — chartShare.ts handles the compact URL form separately.
import type { Platform } from "./platform";
+import { newGroup, newLeaf, stringifyRoot } from "./searchQuery";
export type TimeBin = "week" | "month" | "quarter" | "year";
@@ -73,10 +74,16 @@ export type GalleryTemplate = {
config: ChartConfig;
};
+export type GalleryGroup = {
+ id: string;
+ title: string;
+ templates: GalleryTemplate[];
+};
+
export type ChartTemplates = {
version: 1;
defaultDashboard: Dashboard;
- gallery: GalleryTemplate[];
+ gallery: GalleryGroup[];
};
// Deterministic id counter (no Date.now/Math.random) so server + client render
@@ -102,11 +109,71 @@ export function newChart(partial: Partial<ChartConfig> = {}): ChartConfig {
};
}
+// ─── Axis labels ───
+// Human-readable labels for chart axes, reused by the renderer (axis titles +
+// single-series legend/tooltip label).
+
+const TIME_BIN_LABEL: Record<TimeBin, string> = {
+ week: "Week",
+ month: "Month",
+ quarter: "Quarter",
+ year: "Year",
+};
+
+const CATEGORY_FIELD_LABEL: Record<CategoryField, string> = {
+ channel: "Channel",
+ platform: "Platform",
+ language: "Language",
+ category: "Category",
+};
+
+const METRIC_FIELD_LABEL: Record<MetricField, string> = {
+ duration: "duration",
+ viewCount: "views",
+ likeCount: "likes",
+ commentCount: "comments",
+ channelFollowerCount: "followers",
+ cueCount: "cues",
+};
+
+const AGG_LABEL: Record<Exclude<MetricAgg, "count">, string> = {
+ sum: "Total",
+ avg: "Average",
+ median: "Median",
+ max: "Max",
+ min: "Min",
+};
+
+export function xAxisLabel(x: XAxisField): string {
+ switch (x.kind) {
+ case "time":
+ return TIME_BIN_LABEL[x.bin];
+ case "category":
+ return CATEGORY_FIELD_LABEL[x.field];
+ case "numericBucket":
+ return "Duration";
+ }
+}
+
+export function yAxisLabel(config: Pick<ChartConfig, "y" | "search">): string {
+ if (config.search) {
+ return config.search.metric === "totalHits" ? "Hits" : "Matched videos";
+ }
+ if (config.y.agg === "count") return "Videos";
+ return `${AGG_LABEL[config.y.agg]} ${METRIC_FIELD_LABEL[config.y.field]}`;
+}
+
// ─── Presets ───
// Code-level fallbacks used when no editor-authored chart-templates.json is
-// present. Also seed the editor's authoring UI.
+// present. Also seed the editor's authoring UI and the "Add chart" gallery.
-export const PRESETS: GalleryTemplate[] = [
+// A one-leaf search tree seeded with an (optionally empty) term; search-starter
+// templates leave it empty so adding one opens the editor to fill in a term.
+function searchTree(query = "", scope: "transcripts" | "chat" | "metadata" = "transcripts"): string {
+ return stringifyRoot(newGroup({ children: [newLeaf({ query, scope })] }));
+}
+
+const OVERVIEW_TEMPLATES: GalleryTemplate[] = [
{
id: "preset-library-growth",
title: "Library growth over time",
@@ -170,17 +237,61 @@ export const PRESETS: GalleryTemplate[] = [
},
];
+// Search-derived starters. The query term is empty — adding one opens the
+// editor so the user types what to search for.
+const SEARCH_TEMPLATES: GalleryTemplate[] = [
+ {
+ id: "preset-mentions-over-time",
+ title: "Mentions of a term over time",
+ description: "Total transcript hits for a term, by upload month. Add and set the term.",
+ config: newChart({
+ id: "preset-mentions-over-time",
+ title: "Mentions over time",
+ type: "line",
+ x: { kind: "time", bin: "month" },
+ y: { agg: "count" },
+ search: { queryTree: searchTree(), metric: "totalHits" },
+ }),
+ },
+ {
+ id: "preset-matching-videos-per-month",
+ title: "Videos matching a term per month",
+ description: "How many videos match a term each month. Add and set the term.",
+ config: newChart({
+ id: "preset-matching-videos-per-month",
+ title: "Matching videos per month",
+ type: "area",
+ x: { kind: "time", bin: "month" },
+ y: { agg: "count" },
+ search: { queryTree: searchTree(), metric: "matchedVideos" },
+ }),
+ },
+ {
+ id: "preset-matches-by-channel",
+ title: "Term matches by channel",
+ description: "Which channels mention a term most. Add and set the term.",
+ config: newChart({
+ id: "preset-matches-by-channel",
+ title: "Matches by channel",
+ type: "bar",
+ x: { kind: "category", field: "channel" },
+ y: { agg: "count" },
+ search: { queryTree: searchTree(), metric: "matchedVideos" },
+ }),
+ },
+];
+
+export const PRESET_GROUPS: GalleryGroup[] = [
+ { id: "overview", title: "Overview", templates: OVERVIEW_TEMPLATES },
+ { id: "search", title: "Search examples", templates: SEARCH_TEMPLATES },
+];
+
export function defaultDashboard(): Dashboard {
return {
version: 1,
title: "Overview",
filters: {},
- charts: [
- PRESETS[0].config,
- PRESETS[1].config,
- PRESETS[2].config,
- PRESETS[3].config,
- ],
+ charts: OVERVIEW_TEMPLATES.slice(0, 4).map((t) => t.config),
};
}
@@ -188,6 +299,6 @@ export function defaultTemplates(): ChartTemplates {
return {
version: 1,
defaultDashboard: defaultDashboard(),
- gallery: PRESETS,
+ gallery: PRESET_GROUPS,
};
}
diff --git a/common/lib/chartsStore.ts b/common/lib/chartsStore.ts
@@ -9,6 +9,8 @@ import {
defaultTemplates,
type ChartTemplates,
type Dashboard,
+ type GalleryGroup,
+ type GalleryTemplate,
} from "./chartConfig";
const EXPORT_TEMPLATES_FILE = "chart-templates.json";
@@ -17,6 +19,21 @@ export function exportTemplatesPath(paths: Paths): string {
return path.join(paths.exportPublicDir, EXPORT_TEMPLATES_FILE);
}
+// Accept the current grouped gallery; migrate a legacy flat array (items with
+// `config`) by wrapping it in a single group.
+function coerceGallery(raw: unknown, fallback: GalleryGroup[]): GalleryGroup[] {
+ if (!Array.isArray(raw) || raw.length === 0) return fallback;
+ const isGroup = (g: unknown): g is GalleryGroup =>
+ !!g && typeof g === "object" && Array.isArray((g as GalleryGroup).templates);
+ const isTemplate = (t: unknown): t is GalleryTemplate =>
+ !!t && typeof t === "object" && "config" in (t as object);
+ if (raw.every(isGroup)) return raw as GalleryGroup[];
+ if (raw.every(isTemplate)) {
+ return [{ id: "gallery", title: "Templates", templates: raw as GalleryTemplate[] }];
+ }
+ return fallback;
+}
+
function coerce(raw: unknown): ChartTemplates {
if (!raw || typeof raw !== "object") return defaultTemplates();
const r = raw as Partial<ChartTemplates>;
@@ -27,7 +44,7 @@ function coerce(raw: unknown): ChartTemplates {
r.defaultDashboard && Array.isArray(r.defaultDashboard.charts)
? (r.defaultDashboard as Dashboard)
: base.defaultDashboard,
- gallery: Array.isArray(r.gallery) ? r.gallery : base.gallery,
+ gallery: coerceGallery(r.gallery, base.gallery),
};
}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,7 +1,7 @@
# 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.
+- **Charts.** A new **Charts** tab (a prominent primary nav item alongside Search) 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. Each chart has labelled axes and hover tooltips. 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). The editor sets the data source with a clear Metadata/Search toggle. Charts open with an editor-authored default dashboard plus a grouped gallery of templates — including ready-made search examples — that you can fork and customise. Your board auto-saves locally and survives reloads; **Reset to default** restores the editor's dashboard. **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.
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -42,16 +42,26 @@ export default async function RootLayout({
<QueryProvider>
<header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
- <Link href="/" className="font-semibold tracking-tight">
- {settings.headerTitle}
- </Link>
- <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
+ <div className="flex items-center gap-5 min-w-0">
+ <Link href="/" className="font-semibold tracking-tight shrink-0">
+ {settings.headerTitle}
</Link>
+ <nav className="flex items-center gap-4 text-sm font-medium">
+ <Link
+ href="/"
+ className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400"
+ >
+ Search
+ </Link>
+ <Link
+ href="/charts"
+ className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400"
+ >
+ Charts
+ </Link>
+ </nav>
+ </div>
+ <nav className="text-sm shrink-0">
<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
@@ -62,7 +62,7 @@ test.describe("charts", () => {
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.getByRole("button", { name: "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.
@@ -70,4 +70,64 @@ test.describe("charts", () => {
timeout: 20_000,
});
});
+
+ test("charts render axis labels", async ({ page }) => {
+ await page.goto("/charts");
+ await expect(page.locator(".recharts-surface").first()).toBeVisible();
+ // X axis label for a month-binned chart, Y labels for count + sum-views.
+ await expect(page.getByText("Month", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Videos", { exact: true }).first()).toBeVisible();
+ await expect(
+ page.getByText("Total views", { exact: true }).first(),
+ ).toBeVisible();
+ });
+
+ test("editor data-source toggle swaps metadata vs search fields", async ({
+ page,
+ }) => {
+ await page.goto("/charts");
+ await page.getByRole("button", { name: "New blank" }).click();
+ // Metadata mode by default: Y-metric shown, no term input.
+ await expect(page.getByText("Y metric")).toBeVisible();
+ await expect(page.getByPlaceholder("e.g. bitcoin")).toHaveCount(0);
+ // Switch to Search: term input shown, Y-metric gone.
+ await page.getByRole("button", { name: "Search matches" }).click();
+ await expect(page.getByPlaceholder("e.g. bitcoin")).toBeVisible();
+ await expect(page.getByText("Y metric")).toHaveCount(0);
+ });
+
+ test("gallery is grouped and search templates open the editor", async ({
+ page,
+ }) => {
+ await page.goto("/charts");
+ await expect(page.locator(".recharts-surface").first()).toBeVisible();
+ await page.getByRole("button", { name: "Add chart" }).click();
+ // Group headings inside the gallery panel ("Overview" also appears as the
+ // dashboard title, so assert the uniquely-named search group).
+ await expect(page.getByText("Search examples")).toBeVisible();
+ // Adding a search starter (empty term) opens the editor to fill it in.
+ await page.getByRole("button", { name: "Mentions of a term over time" }).click();
+ await expect(page.getByPlaceholder("e.g. bitcoin")).toBeVisible();
+ });
+
+ test("viewer edits persist across a reload and Reset clears them", async ({
+ page,
+ }) => {
+ await page.goto("/charts");
+ await expect(page.locator(".recharts-surface").first()).toBeVisible();
+ // Rename the first chart.
+ await page.getByRole("button", { name: "Edit" }).first().click();
+ await page.locator("label", { hasText: "Title" }).locator("input").fill("PERSISTED CHART");
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "PERSISTED CHART" })).toBeVisible();
+
+ // Fresh navigation with no query param → restored from localStorage.
+ await page.goto("/charts");
+ await expect(page.getByRole("heading", { name: "PERSISTED CHART" })).toBeVisible();
+
+ // Reset returns to the default board.
+ await page.getByRole("button", { name: "Reset to default" }).click();
+ await expect(page.getByRole("heading", { name: "PERSISTED CHART" })).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Uploads per month" })).toBeVisible();
+ });
});
diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts
@@ -168,18 +168,49 @@ export function chartTemplates() {
},
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: {},
- },
+ id: "overview",
+ title: "Overview",
+ templates: [
+ {
+ 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: {},
+ },
+ },
+ ],
+ },
+ {
+ id: "search",
+ title: "Search examples",
+ templates: [
+ {
+ id: "g-mentions",
+ title: "Mentions of a term over time",
+ config: {
+ id: "g-mentions",
+ title: "Mentions over time",
+ type: "line",
+ x: { kind: "time", bin: "month" },
+ y: { agg: "count" },
+ groupBy: "none",
+ cumulative: false,
+ filters: {},
+ // Empty term → adding it should open the editor.
+ search: {
+ queryTree: '{"k":"g","o":"AND","c":[{"k":"l","q":"","s":"transcripts"}]}',
+ metric: "totalHits",
+ },
+ },
+ },
+ ],
},
],
};