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

@ -14,11 +14,18 @@ export interface PostMetrics {
shares: number;
clicks: number;
engagementRate: number; // (reactions+comments+shares+clicks)/impressions * 100
// NOTE: `saves` and `dwell` are intentionally absent. They are NOT in the
// LinkedIn analytics CSV export this tool parses, and there is no self-serve API
// to pull them. Saves are visible (count-only) in the native post analytics UI
// (~Sept 2025 onward) — read them there; dwell is internal to LinkedIn for
// organic posts. Do not add these fields without a real ingest source.
// `saves` is OPTIONAL and manually entered. LinkedIn's CSV export does NOT
// include it and there is no self-serve API to pull it — but the count IS
// visible in the native post analytics UI (~Sept 2025 onward). The ingest
// path is the user adding a `Saves` column to the CSV they read off it; the
// parser picks it up when present (see csv-parser.ts). When the column or a
// cell is absent, `saves` stays undefined — "unknown", never coerced to 0.
// It is deliberately NOT folded into engagementRate (which stays comparable
// to historical, saves-free data) — saves is surfaced as its own signal.
saves?: number;
// NOTE: `dwell` remains absent and unmeasurable. Dwell time is internal to
// LinkedIn for organic posts — not exportable, no UI count to transcribe, no
// API. Do not fabricate a dwell field or surface.
}
export interface AnalyticsBatch {
@ -40,6 +47,7 @@ export interface WeeklyReport {
totalComments: number;
totalShares: number;
totalClicks: number;
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
avgEngagementRate: number;
avgImpressionsPerPost: number;
};
@ -59,6 +67,21 @@ export interface WeeklyReport {
export type TrendDirection = "up" | "down" | "stable";
/**
* Metric keys that are always present and numeric safe for trend/alert ranking
* and `metrics[key]` index access. Excludes the optional, manually-entered
* `saves`, which is sparse and would type as `number | undefined` under index
* access (and is not a rankable trend metric). This is the runtime whitelist the
* CLI and alert engine have always used.
*/
export type RankableMetric =
| "impressions"
| "reactions"
| "comments"
| "shares"
| "clicks"
| "engagementRate";
export interface Alert {
type: "spike" | "drop" | "milestone";
severity: "info" | "warning" | "critical";
@ -98,6 +121,7 @@ export interface MonthlyReport {
totalComments: number;
totalShares: number;
totalClicks: number;
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
avgEngagementRate: number;
avgImpressionsPerPost: number;
};