// 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"; import { newGroup, newLeaf, stringifyRoot } from "./searchQuery"; export type TimeBin = "week" | "month" | "quarter" | "year"; // Which date field the time X-axis bins on. "uploadDate" (default) charts when // the creator published; the acquisition dates chart when *we* added the content // (the "content added over time" progress charts). export type TimeField = "uploadDate" | "downloadedDate" | "transcribedDate"; export type CategoryField = | "channel" | "platform" | "language" | "category" | "type" | "hasTranscript" | "tag" | "mediaType" | "status"; export type NumericBucketField = "duration"; export type XAxisField = // `field` is optional and defaults to "uploadDate" so charts/URLs authored // before acquisition dates existed still parse and behave identically. | { kind: "time"; bin: TimeBin; field?: TimeField } | { 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" | "coverage"; export type YMetric = | { agg: "count" } | { agg: Exclude; field: MetricField }; export type ChartType = "line" | "bar" | "area" | "stackedBar" | "pie"; export type SeriesGroupBy = | "none" | "channel" | "platform" | "language" | "mediaType" | "status" // One series per content site. Many-to-many: a video's channel can belong to // several sites, so it contributes to each. Only meaningful where a channel→ // sites map is supplied (the Archilyzer hub passes one via ChannelSitesContext); // elsewhere it degrades to grouping by channel slug. See chartAggregate.ts. | "site"; 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 GalleryGroup = { id: string; title: string; templates: GalleryTemplate[]; }; // Only the default dashboard is editor-authored/persisted. The gallery of // add-able templates (including the search-derived starters) always comes from // the code-level PRESET_GROUPS below, so it can never go stale against a saved // file. export type ChartTemplates = { version: 1; defaultDashboard: Dashboard; }; // 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 { 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, }; } // ─── Axis labels ─── // Human-readable labels for chart axes, reused by the renderer (axis titles + // single-series legend/tooltip label). const TIME_BIN_LABEL: Record = { week: "Week", month: "Month", quarter: "Quarter", year: "Year", }; const CATEGORY_FIELD_LABEL: Record = { channel: "Channel", platform: "Platform", language: "Language", category: "Category", type: "Type", hasTranscript: "Transcript", tag: "Tag", mediaType: "Media type", status: "Status", }; const METRIC_FIELD_LABEL: Record = { duration: "duration", viewCount: "views", likeCount: "likes", commentCount: "comments", channelFollowerCount: "followers", cueCount: "cues", coverage: "transcript coverage", }; const AGG_LABEL: Record, 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): 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 and the "Add chart" gallery. // 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", useRegex = false, ): string { return stringifyRoot( newGroup({ children: [newLeaf({ query, scope, useRegex })] }), ); } const OVERVIEW_TEMPLATES: 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" }, }), }, ]; // Progress report: when content was *added to the sites*, as opposed to when the // creator uploaded it (OVERVIEW_TEMPLATES). These bin the time axis on the // acquisition date fields populated by the stats build from the download/ // transcribe outcome sidecars. Grouped by channel so the per-channel breakdown // the charts UI offers is on by default. const PROGRESS_TEMPLATES: GalleryTemplate[] = [ { id: "preset-added-growth", title: "Library growth (added)", description: "Cumulative count of videos by the month we downloaded them.", config: newChart({ id: "preset-added-growth", title: "Library growth (added)", type: "area", x: { kind: "time", bin: "month", field: "downloadedDate" }, y: { agg: "count" }, groupBy: "channel", cumulative: true, }), }, { id: "preset-transcribed-over-time", title: "Transcribed over time", description: "Cumulative count of videos by the month we transcribed them.", config: newChart({ id: "preset-transcribed-over-time", title: "Transcribed over time", type: "area", x: { kind: "time", bin: "month", field: "transcribedDate" }, y: { agg: "count" }, groupBy: "channel", cumulative: true, }), }, { id: "preset-added-per-month", title: "Added per month", description: "Videos downloaded each month, stacked by channel.", config: newChart({ id: "preset-added-per-month", title: "Added per month", type: "stackedBar", x: { kind: "time", bin: "month", field: "downloadedDate" }, y: { agg: "count" }, groupBy: "channel", }), }, ]; // 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" }, }), }, ]; // Search examples first — search-derived charts are the headline use case. export const PRESET_GROUPS: GalleryGroup[] = [ { id: "search", title: "Search examples", templates: SEARCH_TEMPLATES }, { id: "overview", title: "Overview", templates: OVERVIEW_TEMPLATES }, { id: "progress", title: "Content added", templates: PROGRESS_TEMPLATES }, ]; // ─── "Chart this search" handoff ─── // The starting templates offered by the search page's "Chart this search" menu. // Each reuses a SEARCH_TEMPLATES config as the shape (x axis / type / metric / // grouping); the live query tree is swapped in by chartFromSearch(). export const CHART_THIS_SEARCH_TEMPLATES: { id: string; label: string; base: ChartConfig; }[] = [ { id: "mentions-over-time", label: "Mentions over time", base: SEARCH_TEMPLATES[0].config, }, { id: "matching-videos-per-month", label: "Matching videos per month", base: SEARCH_TEMPLATES[1].config, }, { id: "matches-by-channel", label: "Matches by channel", base: SEARCH_TEMPLATES[2].config, }, ]; // Build a chart from a "Chart this search" template + a live serialized query // tree (+ optional carried filters). Keeps the template's x/type/metric/group, // swaps in the real query, and assigns a fresh id. export function chartFromSearch( templateId: string, queryTree: string, filters: ChartFilters = {}, ): ChartConfig { const tpl = CHART_THIS_SEARCH_TEMPLATES.find((t) => t.id === templateId) ?? CHART_THIS_SEARCH_TEMPLATES[0]; return newChart({ ...tpl.base, id: nextChartId(), filters, search: { queryTree, metric: tpl.base.search?.metric ?? "matchedVideos", }, }); } // YYYYMMDD for `yearsBack` years before today. Used to scope the default // search charts to a recent window so they don't search the whole corpus on // load. Computed at build time (baked into chart-templates.json). export function recentCutoffYmd(yearsBack: number): string { const d = new Date(); d.setFullYear(d.getFullYear() - yearsBack); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}${m}${day}`; } export const DEFAULT_SEARCH_YEARS = 2; // Default search charts seeded with example terms so the board demonstrates the // search feature out of the box. Date-limited to the recent window so the // on-load search stays light; widen the date range to search further back. const DEFAULT_SEARCH_CHARTS: ChartConfig[] = [ newChart({ id: "default-tim-pool", title: '“Tim Pool” mentions over time', type: "line", x: { kind: "time", bin: "month" }, y: { agg: "count" }, filters: { dateFrom: recentCutoffYmd(DEFAULT_SEARCH_YEARS) }, search: { queryTree: searchTree("Tim Pool"), metric: "totalHits" }, }), newChart({ id: "default-under-attack", title: 'Videos mentioning “under attack”', type: "area", x: { kind: "time", bin: "month" }, y: { agg: "count" }, filters: { dateFrom: recentCutoffYmd(DEFAULT_SEARCH_YEARS) }, search: { queryTree: searchTree("under attack"), metric: "matchedVideos" }, }), ]; export function defaultDashboard(): Dashboard { return { version: 1, title: "Overview", filters: {}, // Search charts first, then the metadata overview charts, then the headline // "content added" progress chart so the acquisition view is present by // default in both the editor dashboard and the export sites. charts: [ ...DEFAULT_SEARCH_CHARTS, ...OVERVIEW_TEMPLATES.slice(0, 4).map((t) => t.config), PROGRESS_TEMPLATES[0].config, ], }; } export function defaultTemplates(): ChartTemplates { return { version: 1, defaultDashboard: defaultDashboard(), }; } // ─── Archilyzer hub (homepage) default dashboard ─── // The cross-site board for the marketing/hub site. Leads with a per-site // breakdown of content acquired over time (groupBy "site", resolved through the // channel→sites map the hub supplies), plus combined whole-pool totals so "both" // views are present out of the box. Bins on the acquisition date fields. export function defaultHomepageDashboard(): Dashboard { return { version: 1, title: "Across all sites", filters: {}, charts: [ newChart({ id: "hub-downloaded-by-site", title: "Downloaded over time, by site", type: "area", x: { kind: "time", bin: "month", field: "downloadedDate" }, y: { agg: "count" }, groupBy: "site", cumulative: true, }), newChart({ id: "hub-transcribed-by-site", title: "Transcribed over time, by site", type: "area", x: { kind: "time", bin: "month", field: "transcribedDate" }, y: { agg: "count" }, groupBy: "site", cumulative: true, }), newChart({ id: "hub-downloaded-total", title: "Downloaded per month (all sites)", type: "bar", x: { kind: "time", bin: "month", field: "downloadedDate" }, y: { agg: "count" }, }), newChart({ id: "hub-library-growth", title: "Library growth (added, all sites)", type: "area", x: { kind: "time", bin: "month", field: "downloadedDate" }, y: { agg: "count" }, cumulative: true, }), ], }; } export function defaultHomepageTemplates(): ChartTemplates { return { version: 1, defaultDashboard: defaultHomepageDashboard(), }; }