Archilyzer · Source

archilyzer

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

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:
Mcommon/components/charts/ChartConfigEditor.tsx | 128++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcommon/components/charts/ChartView.tsx | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcommon/components/charts/ChartsDashboard.tsx | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcommon/lib/chartConfig.ts | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcommon/lib/chartsStore.ts | 19++++++++++++++++++-
Mexport/CHANGELOG.md | 2+-
Mexport/app/layout.tsx | 28+++++++++++++++++++---------
Mexport/e2e/charts.spec.ts | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mexport/e2e/fixtures/data.ts | 55+++++++++++++++++++++++++++++++++++++++++++------------
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", + }, + }, + }, + ], }, ], };