linkedin-studio/scripts/analytics/src/cli.ts
Kjell Tore Guttormsen e2ad190dda feat(linkedin-studio): N17 — baseline-motor (median + variansbånd + minimum-N-refusal) [skip-docs]
Every reading now leads with "vs your own baseline", and no verdict is given
when N is too small to carry one.

- stats.ts: median + medianAbsoluteDeviation (robust pair; mean/stddev stay for
  the alert engine, which wants outlier sensitivity), rollingBaseline with a
  10-post positional window, median ± 1·MAD band floored at 0, and a typed
  insufficient-data refusal below MIN_BASELINE_N=5. readAgainstBaseline returns
  above/within/below-band, or no-verdict when the baseline was refused.
- baselineByGroup + buildBaselineBlock: per-format/per-pillar baselines, each
  judged on its own N; the reported period is excluded from its own baseline and
  compared on its median, not its mean.
- queue-join.ts (new): read-only date join supplying format/pillar from the post
  queue. Every ambiguity resolves to unlabelled, an entry labels at most one
  post, and a missing/broken queue degrades to no labels.
- weekly/monthly reports attach the block unconditionally (refusal included);
  optional in the types, so pre-N17 reports load unchanged.
- CLI: report output leads with the baseline; new `baseline [--by format|pillar]`
  verb with coverage reporting.
- report.md leads with baseline framing and prints the code's reading rather
  than judging the band by eye; WoW loses to the baseline on disagreement.
  analyze.md Step 2a tests whether the drop is real before diagnosing it.

TDD: 58 analytics tests written red first (144 -> 202). test-runner Section 16x,
23 unconditional checks + self-test (247 -> 270; anti-erosion floor 228 -> 251).
tsc clean. All suites green: trends 300, brain 134, editions 72,
specifics-bank 45, contract-gate 33, hooks 191, tests 35, render 60.

Also closes the OKF phase-4 scope follow-up in docs/okf-ingestion/plan.md §8
(coord round 2026-07-25): phase 4 tracks the contract, parse is in scope, and
our claim on read_concept/navigate_bundle is withdrawn as unnecessary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
2026-07-25 20:50:45 +02:00

665 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { parseLinkedInCSV } from "./parsers/csv-parser.js";
import {
getAnalyticsRoot,
getDataRoot,
ensureDirectories,
saveBatch,
loadAllPosts,
} from "./utils/storage.js";
import { detectAlerts } from "./utils/alerts.js";
import {
mean,
standardDeviation,
weightedOutOfNetworkPct,
rollingBaseline,
baselineByGroup,
BASELINE_WINDOW,
MIN_BASELINE_N,
} from "./utils/stats.js";
import { loadQueueEntries, labelPosts } from "./utils/queue-join.js";
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { RankableMetric, Baseline, BaselineBlock, BaselineReading } from "./models/types.js";
const args = process.argv.slice(2);
const command = args[0];
function parseOption(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
}
/**
* Per-post saves suffix for report lines. Empty string when the post carries no
* manual saves data, so saves-free output stays identical to the pre-saves CLI.
*/
function savesSuffix(saves?: number): string {
return saves !== undefined ? ` | ${saves.toLocaleString()} saves` : "";
}
/**
* Per-post reach suffix. Empty string when the post carries no manual
* out-of-network share, so reach-free output stays identical to the pre-N16 CLI.
*/
function reachSuffix(outOfNetworkPct?: number): string {
return outOfNetworkPct !== undefined ? ` | ${outOfNetworkPct}% out-of-network` : "";
}
/** Round to one decimal — keeps the derived in-network half free of float noise. */
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/** Plain-language rendering of a band reading (N17). */
const READING_TEXT: Record<BaselineReading, string> = {
"above-band": "ABOVE your normal range",
"within-band": "inside your normal range (normal variation, not a trend)",
"below-band": "BELOW your normal range",
"no-verdict": "no verdict",
};
/**
* Print the baseline block FIRST, before absolute totals (N17).
*
* Ordering is the point: the operator's own normal is the only frame in which a
* weekly number means anything at ~2 posts/week, so it leads and the absolutes
* follow. On a refusal this prints the refusal — the absolutes still get shown
* below, but with no verdict attached to them.
*/
function printBaseline(baseline: BaselineBlock | undefined, unit: "week" | "month") {
if (!baseline) return; // report generated before baselines existed
console.log(`Vs Your Own Baseline (last ${baseline.window} posts before this ${unit})`);
console.log("─────────────────────────────────────");
const line = (
label: string,
base: BaselineBlock["impressions"],
period: { median?: number; reading: BaselineReading },
fmt: (value: number) => string
) => {
if (base.status !== "ok") {
console.log(`${label} ${base.reason}`);
return;
}
const own = period.median !== undefined ? fmt(period.median) : "no posts";
console.log(
`${label} this ${unit} ${own} vs baseline ${fmt(base.median)} ` +
`(normal range ${fmt(base.band.low)}${fmt(base.band.high)}, n=${base.n})`
);
console.log(`${" ".repeat(label.length)}${READING_TEXT[period.reading]}`);
};
line(
"Impressions/post:",
baseline.impressions,
baseline.period.impressions,
(value) => Math.round(value).toLocaleString()
);
line(
"Engagement rate: ",
baseline.engagementRate,
baseline.period.engagementRate,
(value) => `${value.toFixed(2)}%`
);
console.log();
}
function printUsage() {
console.log(`
LinkedIn Analytics CLI
Usage:
node --import tsx src/cli.ts import <filename> Import a CSV export
node --import tsx src/cli.ts report [--week W] Generate weekly report
node --import tsx src/cli.ts report --month YYYY-MM Generate monthly report with MoM comparison
node --import tsx src/cli.ts trends [--period P] [--metric M] Show trends and alerts
node --import tsx src/cli.ts heatmap Day-of-week performance matrix
node --import tsx src/cli.ts baseline [--by format|pillar] Your own normal, per format and pillar
Options:
--week W ISO week (e.g., 2026-W05), defaults to current week
--by G Group the baseline by "format" or "pillar" (default: both)
--period P Time period: "week" | "month" | "quarter" | "all" (default: "month")
--metric M Metric to analyze: "impressions" | "reactions" | "comments" | "shares" | "clicks" | "engagementRate" (default: "impressions")
Examples:
node --import tsx src/cli.ts import linkedin-export-2026-01-20.csv
node --import tsx src/cli.ts report --week 2026-W04
node --import tsx src/cli.ts trends --period quarter --metric engagementRate
`);
}
async function handleImport(root: string, args: string[]) {
const filename = args[1];
if (!filename) {
console.error("Error: Missing filename argument");
console.error("Usage: node --import tsx src/cli.ts import <filename>");
process.exit(1);
}
const fullPath = join(root, "exports", filename);
if (!existsSync(fullPath)) {
console.error(`Error: File not found: ${fullPath}`);
console.error(`\nMake sure the CSV file is placed in: ${join(root, "exports")}`);
process.exit(1);
}
console.log(`Importing ${filename}...`);
try {
const batch = parseLinkedInCSV(fullPath, filename);
const savedFilename = saveBatch(root, batch);
console.log("\nImport successful!");
console.log("─────────────────────────────────────");
console.log(`Posts imported: ${batch.postCount}`);
console.log(`Date range: ${batch.dateRange.from} to ${batch.dateRange.to}`);
console.log(`Batch ID: ${batch.batchId}`);
console.log(`Saved to: posts/${savedFilename}`);
// Surface manually-entered saves when the CSV carried a Saves column.
const savesPosts = batch.posts.filter((p) => p.metrics.saves !== undefined);
if (savesPosts.length > 0) {
const totalSaves = savesPosts.reduce((sum, p) => sum + (p.metrics.saves ?? 0), 0);
console.log(`Saves entered: ${totalSaves.toLocaleString()} across ${savesPosts.length} post(s) (manual)`);
}
// Same for the reach split when the CSV carried an Out-of-network (or
// In-network) column. Counting the posts that carry a reading makes partial
// coverage visible instead of implying the whole batch was transcribed.
const reachPosts = batch.posts.filter((p) => p.metrics.outOfNetworkPct !== undefined);
if (reachPosts.length > 0) {
const weighted = weightedOutOfNetworkPct(batch.posts);
console.log(`Reach entered: ${weighted}% out-of-network across ${reachPosts.length} post(s) (manual, impressions-weighted)`);
}
// Run alert detection on imported posts
const alerts = detectAlerts(batch.posts, "impressions");
if (alerts.length > 0) {
console.log("\nImmediate alerts detected:");
console.log("─────────────────────────────────────");
for (const alert of alerts.slice(0, 5)) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
if (alerts.length > 5) {
console.log(`\n... and ${alerts.length - 5} more alerts`);
}
} else {
console.log("\nNo anomalies detected in imported data.");
}
} catch (err) {
console.error(`Error parsing CSV: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleReport(root: string, args: string[]) {
const monthOption = parseOption(args, "--month");
if (monthOption) {
return handleMonthlyReport(root, monthOption);
}
const weekOption = parseOption(args, "--week");
const week = weekOption || getCurrentISOWeek();
console.log(`Generating weekly report for ${week}...`);
try {
const report = generateWeeklyReport(root, week);
console.log("\nWeekly Report");
console.log("═════════════════════════════════════");
console.log(`Week: ${report.week}`);
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
printBaseline(report.baseline, "week");
console.log("Summary");
console.log("─────────────────────────────────────");
console.log(`Total posts: ${report.summary.totalPosts}`);
console.log(`Total impressions: ${report.summary.totalImpressions.toLocaleString()}`);
console.log(`Total reactions: ${report.summary.totalReactions.toLocaleString()}`);
console.log(`Total comments: ${report.summary.totalComments.toLocaleString()}`);
console.log(`Total shares: ${report.summary.totalShares.toLocaleString()}`);
console.log(`Total clicks: ${report.summary.totalClicks.toLocaleString()}`);
if (report.summary.totalSaves !== undefined) {
console.log(`Total saves: ${report.summary.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
if (report.summary.avgOutOfNetworkPct !== undefined) {
console.log(`Out-of-network: ${report.summary.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`);
console.log(`In-network: ${round1(100 - report.summary.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`);
}
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
console.log();
if (report.topPerformers.length > 0) {
console.log("Top Performers");
console.log("─────────────────────────────────────");
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
}
console.log();
}
if (report.underperformers.length > 0) {
console.log("Underperformers");
console.log("─────────────────────────────────────");
for (const post of report.underperformers.slice(0, 3)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
}
console.log();
}
console.log("Trends");
console.log("─────────────────────────────────────");
console.log(`Impressions trend: ${report.trends.impressionsTrend.toUpperCase()} (${report.trends.percentChange.impressions > 0 ? "+" : ""}${report.trends.percentChange.impressions.toFixed(1)}%)`);
console.log(`Engagement trend: ${report.trends.engagementTrend.toUpperCase()} (${report.trends.percentChange.engagement > 0 ? "+" : ""}${report.trends.percentChange.engagement.toFixed(1)}%)`);
console.log(`Compared to: ${report.trends.comparedTo}`);
console.log();
if (report.alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of report.alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
console.log();
}
console.log(`Report saved to: weekly-reports/${week}.json`);
} catch (err) {
console.error(`Error generating report: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
/**
* Type guard to check if a string is a rankable (always-numeric) metric key.
* Excludes the optional, manually-entered `saves` — it is not a trend metric.
*/
function isPostMetric(value: string): value is RankableMetric {
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
"shares",
"clicks",
"engagementRate",
];
return validMetrics.includes(value as RankableMetric);
}
async function handleTrends(root: string, args: string[]) {
const periodOption = parseOption(args, "--period") || "month";
const metricOption = parseOption(args, "--metric") || "impressions";
const validPeriods = ["week", "month", "quarter", "all"];
if (!validPeriods.includes(periodOption)) {
console.error(`Error: Invalid period "${periodOption}". Must be one of: ${validPeriods.join(", ")}`);
process.exit(1);
}
if (!isPostMetric(metricOption)) {
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
"shares",
"clicks",
"engagementRate",
];
console.error(`Error: Invalid metric "${metricOption}". Must be one of: ${validMetrics.join(", ")}`);
process.exit(1);
}
const period = periodOption as "week" | "month" | "quarter" | "all";
const metric = metricOption;
console.log(`Analyzing trends for ${metric} over ${period}...`);
try {
const allPosts = loadAllPosts(root);
if (allPosts.length === 0) {
console.error("Error: No posts found. Import some data first.");
process.exit(1);
}
// Calculate date range based on period
const now = new Date();
let fromDate = new Date(0); // Beginning of time for "all"
if (period === "week") {
fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
} else if (period === "month") {
fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
} else if (period === "quarter") {
fromDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
}
const fromDateStr = fromDate.toISOString().split("T")[0];
// Filter posts by period
const filteredPosts = allPosts.filter(
(post) => post.publishedDate >= fromDateStr
);
if (filteredPosts.length === 0) {
console.error(`Error: No posts found in the ${period} period.`);
process.exit(1);
}
// Calculate statistics
const values = filteredPosts.map((post) => post.metrics[metric]);
const avg = mean(values);
const stdDev = standardDeviation(values);
const min = Math.min(...values);
const max = Math.max(...values);
console.log("\nTrend Analysis");
console.log("═════════════════════════════════════");
console.log(`Metric: ${metric}`);
console.log(`Period: ${period}`);
console.log(`Posts analyzed: ${filteredPosts.length}`);
console.log(`Date range: ${filteredPosts[filteredPosts.length - 1].publishedDate} to ${filteredPosts[0].publishedDate}`);
console.log();
console.log("Statistics");
console.log("─────────────────────────────────────");
console.log(`Mean: ${avg.toFixed(2)}`);
console.log(`Std deviation: ${stdDev.toFixed(2)}`);
console.log(`Min: ${min.toFixed(2)}`);
console.log(`Max: ${max.toFixed(2)}`);
console.log();
// Generate alerts
const alerts = detectAlerts(filteredPosts, metric);
if (alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
} else {
console.log("No anomalies detected in this period.");
}
} catch (err) {
console.error(`Error analyzing trends: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleMonthlyReport(root: string, month: string) {
console.log(`Generating monthly report for ${month}...`);
try {
const report = generateMonthlyReport(root, month);
console.log("\nMonthly Report");
console.log("═════════════════════════════════════");
console.log(`Month: ${report.month}`);
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
printBaseline(report.baseline, "month");
console.log("Summary");
console.log("─────────────────────────────────────");
const s = report.summary;
const fmtDelta = (val: number | null, suffix = "%") =>
val !== null ? ` (${val > 0 ? "+" : ""}${val}${suffix})` : "";
console.log(`Posts: ${s.totalPosts}${fmtDelta(report.trends.percentChange.postCount)}`);
console.log(`Impressions: ${s.totalImpressions.toLocaleString()}${fmtDelta(report.trends.percentChange.impressions)}`);
console.log(`Avg per post: ${s.avgImpressionsPerPost.toLocaleString()}`);
console.log(`Avg engagement: ${s.avgEngagementRate.toFixed(2)}%${fmtDelta(report.trends.percentChange.engagement)}`);
console.log(`Reactions: ${s.totalReactions.toLocaleString()}`);
console.log(`Comments: ${s.totalComments.toLocaleString()}`);
console.log(`Shares: ${s.totalShares.toLocaleString()}`);
console.log(`Clicks: ${s.totalClicks.toLocaleString()}`);
if (s.totalSaves !== undefined) {
console.log(`Saves: ${s.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
if (s.avgOutOfNetworkPct !== undefined) {
console.log(`Out-of-network: ${s.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`);
console.log(`In-network: ${round1(100 - s.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`);
}
console.log();
if (report.byWeek.length > 0) {
console.log("Week Breakdown");
console.log("─────────────────────────────────────");
for (const w of report.byWeek) {
console.log(`${w.week}: ${w.postCount} posts | ${w.avgImpressions.toLocaleString()} avg impr | ${w.avgEngagementRate.toFixed(1)}% eng`);
}
console.log();
}
if (report.topPerformers.length > 0) {
console.log("Top Performers");
console.log("─────────────────────────────────────");
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
}
console.log();
}
if (report.trends.comparedTo) {
console.log(`Compared to: ${report.trends.comparedTo}`);
} else {
console.log("No previous month data for comparison.");
}
console.log();
if (report.alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of report.alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
console.log();
}
console.log(`Report saved to: monthly-reports/${month}.json`);
} catch (err) {
console.error(`Error generating monthly report: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleHeatmap(root: string) {
console.log("Generating day-of-week heatmap...");
try {
const allPosts = loadAllPosts(root);
if (allPosts.length === 0) {
console.error("Error: No posts found. Import some data first.");
process.exit(1);
}
const report = generateHeatmap(allPosts);
console.log("\nDay-of-Week Performance Heatmap");
console.log("═════════════════════════════════════");
console.log(`Posts analyzed: ${report.postsAnalyzed}`);
console.log(`Date range: ${report.dateRange.from} to ${report.dateRange.to}`);
console.log();
// Print table header
const days = report.byDayOfWeek.map(d => d.dayName.slice(0, 3).padStart(7));
console.log(` ${days.join("")}`);
console.log(` ${"───────".repeat(7)}`);
// Posts row
const postCounts = report.byDayOfWeek.map(d => String(d.postCount).padStart(7));
console.log(`Posts: ${postCounts.join("")}`);
// Impressions row
const impressions = report.byDayOfWeek.map(d =>
d.postCount > 0 ? d.avgImpressions.toLocaleString().padStart(7) : " -"
);
console.log(`Impr: ${impressions.join("")}`);
// Engagement rate row
const engRates = report.byDayOfWeek.map(d =>
d.postCount > 0 ? `${d.avgEngagementRate.toFixed(1)}%`.padStart(7) : " -"
);
console.log(`Eng: ${engRates.join("")}`);
console.log();
console.log(`Best day for impressions: ${report.bestDayImpressions}`);
console.log(`Best day for engagement: ${report.bestDayEngagement}`);
console.log("\nNote: LinkedIn CSV exports do not include publish time.");
console.log("This heatmap shows day-of-week only.");
} catch (err) {
console.error(`Error generating heatmap: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
/**
* Per-format and per-pillar baselines (N17).
*
* Format and pillar are not analytics data — they come from the post queue via
* a date join (see queue-join.ts), so this surface states its own coverage: how
* many posts it could label, and how many it could not. Each group is judged on
* its own N, so a format the operator has used twice gets a refusal even when
* the overall history is long.
*/
async function handleBaseline(root: string, args: string[]) {
const by = parseOption(args, "--by");
if (by !== undefined && by !== "format" && by !== "pillar") {
console.error(`Unknown grouping: ${by}. Use "format" or "pillar".`);
process.exit(1);
}
const posts = loadAllPosts(root);
if (posts.length === 0) {
console.log("No analytics data imported yet — nothing to build a baseline from.");
return;
}
const queueFile = join(getDataRoot("drafts"), "queue.json");
const entries = loadQueueEntries(queueFile);
const labelled = labelPosts(
[...posts].sort((a, b) => a.publishedDate.localeCompare(b.publishedDate)),
entries
);
console.log("\nYour Own Baseline");
console.log("═════════════════════════════════════");
console.log(`Posts: ${posts.length}`);
console.log(`Window: last ${BASELINE_WINDOW} posts per group, ${MIN_BASELINE_N} required for a verdict`);
if (entries.length === 0) {
console.log(
`\nNo post queue at ${queueFile} — format and pillar are unknown, so only the\n` +
`overall baseline is available. Grouped baselines need queue entries written by\n` +
`the create commands.`
);
}
const groups: Array<"format" | "pillar"> = by ? [by] : ["format", "pillar"];
// Overall first: it is the one baseline that needs no join and can never be
// wrong about which bucket a post belongs to.
const overall = rollingBaseline(labelled.map((post) => post.metrics.impressions));
console.log("\nOverall (impressions/post)");
console.log("─────────────────────────────────────");
printGroupBaseline("all posts", overall);
for (const group of groups) {
const unlabelled = labelled.filter((post) => post[group] === undefined).length;
const byGroup = baselineByGroup(
labelled,
(post) => post[group],
(post) => post.metrics.impressions
);
console.log(`\nBy ${group} (impressions/post)`);
console.log("─────────────────────────────────────");
if (byGroup.size === 0) {
console.log(`No post carries a ${group} label — nothing to group.`);
} else {
for (const [name, baseline] of [...byGroup.entries()].sort(([a], [b]) => a.localeCompare(b))) {
printGroupBaseline(name, baseline);
}
}
if (unlabelled > 0) {
console.log(
`(${unlabelled} of ${labelled.length} posts have no ${group} label and are ` +
`excluded from the groups above — they are counted in Overall.)`
);
}
}
console.log();
}
/** One group's line — a median and band, or the refusal, never both. */
function printGroupBaseline(name: string, baseline: Baseline) {
const label = `${name}:`.padEnd(22);
if (baseline.status !== "ok") {
console.log(`${label}no verdict — ${baseline.n} post(s), ${baseline.required} required`);
return;
}
console.log(
`${label}median ${Math.round(baseline.median).toLocaleString()} ` +
`(normal range ${Math.round(baseline.band.low).toLocaleString()}` +
`${Math.round(baseline.band.high).toLocaleString()}, n=${baseline.n})`
);
}
async function main() {
const root = getAnalyticsRoot();
ensureDirectories(root);
switch (command) {
case "import":
await handleImport(root, args);
break;
case "report":
await handleReport(root, args);
break;
case "trends":
await handleTrends(root, args);
break;
case "heatmap":
await handleHeatmap(root);
break;
case "baseline":
await handleBaseline(root, args);
break;
default:
printUsage();
process.exit(command ? 1 : 0);
}
}
main().catch((err) => {
console.error("Fatal error:", err instanceof Error ? err.message : String(err));
process.exit(1);
});