feat(linkedin-studio): S16 — optional manual saves in analytics + close deferred onboarding Write MAJOR

Lifts the original v4.0.0 Non-Goal: an optional, manually-entered `saves`
metric through the analytics layer, built location-agnostic (option c) so
UI-brief §9b/M0 relocates the data dir in one place later.

- types: PostMetrics.saves? + Weekly/Monthly summary.totalSaves? (optional);
  new RankableMetric type for the always-numeric index-access whitelist
- parser: dedicated parseOptionalCount() — blank/non-numeric/negative -> undefined
  ("unknown != 0"), genuine 0 kept; saves NOT folded into engagementRate
- reports: totalSaves set only when >=1 post carries saves (backward-compat)
- cli: saves surfaced in import summary + weekly/monthly totals + per-post
- S16-pre: onboarding.md allowed-tools gains Write (closes S15-deferred MAJOR)
- docs (three-doc rule): plugin README boundary + analytics README + root README
  + plugin CLAUDE.md + CHANGELOG; dwell stays explicitly unmeasurable

Independent /trekreview: brief-conformance 0 findings; code-correctness 2 MAJOR
(own lockstep misses) FIXED in-session (parseOptionalCount + edge tests). Gate:
tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98. Within-v4.1.0 refinement
(no surface/count/version change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-30 22:23:12 +02:00
commit e029841800
17 changed files with 412 additions and 117 deletions

View file

@ -12,7 +12,7 @@ import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { PostMetrics } from "./models/types.js";
import type { RankableMetric } from "./models/types.js";
const args = process.argv.slice(2);
const command = args[0];
@ -22,6 +22,14 @@ function parseOption(args: string[], flag: string): string | undefined {
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` : "";
}
function printUsage() {
console.log(`
LinkedIn Analytics CLI
@ -75,6 +83,13 @@ async function handleImport(root: string, args: string[]) {
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)`);
}
// Run alert detection on imported posts
const alerts = detectAlerts(batch.posts, "impressions");
@ -126,6 +141,9 @@ async function handleReport(root: string, args: string[]) {
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)`);
}
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
console.log();
@ -136,7 +154,7 @@ async function handleReport(root: string, args: string[]) {
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 | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}
@ -147,7 +165,7 @@ async function handleReport(root: string, args: string[]) {
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 | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}
@ -177,10 +195,11 @@ async function handleReport(root: string, args: string[]) {
}
/**
* Type guard to check if a string is a valid PostMetrics key
* 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 keyof PostMetrics {
const validMetrics: (keyof PostMetrics)[] = [
function isPostMetric(value: string): value is RankableMetric {
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
@ -188,7 +207,7 @@ function isPostMetric(value: string): value is keyof PostMetrics {
"clicks",
"engagementRate",
];
return validMetrics.includes(value as keyof PostMetrics);
return validMetrics.includes(value as RankableMetric);
}
async function handleTrends(root: string, args: string[]) {
@ -203,7 +222,7 @@ async function handleTrends(root: string, args: string[]) {
}
if (!isPostMetric(metricOption)) {
const validMetrics: (keyof PostMetrics)[] = [
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
@ -320,6 +339,9 @@ async function handleMonthlyReport(root: string, month: string) {
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)`);
}
console.log();
if (report.byWeek.length > 0) {
@ -337,7 +359,7 @@ async function handleMonthlyReport(root: string, month: string) {
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 | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}