// 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[]; }; // Extra context the pure aggregation can't derive from VideoStat alone. Today // only `channelSites` (channel slug → the site ids that expose that channel), // supplied by the Archilyzer hub so `groupBy: "site"` can fan a video out to // each of its sites. Optional everywhere else. export type AggregateContext = { channelSites?: Record | null; }; 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": { // Bin on the chosen date field (defaults to uploadDate). Acquisition dates // can be null (no resolvable source) — binTime returns null for those, so // the stat simply contributes to no time bucket. const ymd = stat[x.field ?? "uploadDate"]; const label = ymd ? binTime(ymd, x.bin) : null; 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 })); } if (x.field === "tag") { // A video contributes to each of its tags (multi-value, like category). const tags = stat.tags.length ? stat.tags : [EMPTY]; return tags.map((t) => ({ label: t, sort: t })); } let v: string; switch (x.field) { case "channel": v = stat.channel || stat.channelSlug; break; case "platform": v = stat.platform; break; case "language": v = stat.language || EMPTY; break; case "type": v = stat.isLivestream ? "Livestream" : "Video"; break; case "hasTranscript": v = stat.hasTranscript ? "Has transcript" : "No transcript"; break; case "mediaType": v = stat.mediaType; break; case "status": v = stat.status; break; default: v = EMPTY; } return [{ label: v, sort: v }]; } } } 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 "mediaType": return stat.mediaType; case "status": return stat.status; // "site" is many-to-many and needs the channel→sites map, which this // single-key helper has no access to (it's used by the search path). Degrade // to the channel slug so search-derived "site" charts still produce a series; // metadata charts go through seriesKeysForStat below for the real fan-out. case "site": return stat.channelSlug; case "none": return SERIES_ALL; } } // The series keys a stat contributes to. Usually one; `groupBy: "site"` can be // many (a channel exposed by several sites) or none (a channel on no site, e.g. // pool-only content) — the latter falls into the EMPTY bucket so it still counts // in combined totals. All other groupings delegate to seriesKeyForStat. export function seriesKeysForStat( stat: VideoStat, groupBy: SeriesGroupBy, ctx?: AggregateContext, ): string[] { if (groupBy === "site") { const ids = ctx?.channelSites?.[stat.channelSlug]; return ids && ids.length ? ids : [EMPTY]; } return [seriesKeyForStat(stat, groupBy)]; } 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, ctx?: AggregateContext, ): ChartData { const filtered = applyFilters(stats, config.filters); // seriesKey -> xLabel -> { values, sort } const buckets = new Map< string, Map >(); for (const stat of filtered) { const mv = metricValue(stat, config.y); if (config.y.agg !== "count" && mv === null) continue; // skip missing metric const labels = xLabelsForStat(stat, config.x); if (labels.length === 0) continue; // A stat can contribute to several series (groupBy "site" fan-out); add it // to each so a video shared across sites counts once per site. for (const sKey of seriesKeysForStat(stat, config.groupBy, ctx)) { let xMap = buckets.get(sKey); if (!xMap) { xMap = new Map(); buckets.set(sKey, xMap); } for (const { label, sort } of labels) { 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(); 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; }