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 69fec21f43
17 changed files with 412 additions and 117 deletions

View file

@ -57,6 +57,33 @@ function parseMetric(value: string): number {
return Math.max(0, parsed);
}
/**
* Parse an OPTIONAL manually-entered count (saves). Unlike parseMetric which
* coerces blanks, garbage, and negatives to 0 this preserves the "unknown vs
* zero" distinction the saves contract requires:
* - blank / absent undefined ("unknown", never 0)
* - non-numeric ("n/a", ) undefined ("unknown", never 0)
* - negative undefined (not a real save count)
* - a genuine number ("0") that number (an explicit 0 is a real reading)
* Reuses the same EU/US thousand-separator normalization as parseMetric so a
* "1.234"/"1,234" Saves cell parses consistently with the other columns.
*/
function parseOptionalCount(value: string): number | undefined {
if (!value) return undefined;
const cleaned = value.replace(/"/g, "").trim();
if (cleaned === "") return undefined;
const lastComma = cleaned.lastIndexOf(",");
const lastDot = cleaned.lastIndexOf(".");
const normalized = lastComma > lastDot
? cleaned.replace(/,/g, "")
: cleaned.replace(/\./g, "").replace(/,/g, ".");
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
return parsed;
}
/**
* Normalizes date to YYYY-MM-DD format
* Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD
@ -173,7 +200,8 @@ export function parseLinkedInCSV(
const shares = parseMetric(findColumn(record, ["share", "repost"]));
const clicks = parseMetric(findColumn(record, ["click"]));
// Calculate engagement rate
// Calculate engagement rate — saves is deliberately NOT in the numerator,
// so this stays comparable to historical, saves-free imports.
const totalEngagement = reactions + comments + shares + clicks;
const engagementRate = impressions > 0
? (totalEngagement / impressions) * 100
@ -188,6 +216,15 @@ export function parseLinkedInCSV(
engagementRate,
};
// Optional manual-entry saves: only when the user augmented this CSV with a
// Saves column (read off native LinkedIn analytics, ~Sept 2025+). A missing
// column, a blank cell, or a non-numeric/negative cell stays undefined —
// "unknown", never coerced to 0; a genuine 0 is kept as 0.
const saves = parseOptionalCount(findColumn(record, ["saves", "bookmark"]));
if (saves !== undefined) {
metrics.saves = saves;
}
return {
id: generatePostId(title, date),
title,