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 55c94ee964
18 changed files with 417 additions and 118 deletions

View file

@ -275,6 +275,50 @@ describe("weekly", () => {
assert.ok(Math.abs(report.summary.avgEngagementRate - 8.49) < 0.01);
});
test("should sum saves into totalSaves when posts carry manual saves data", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "saved1",
publishedDate: "2026-01-12", // 2026-W03
metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 20, engagementRate: 8.5, saves: 12 },
}),
createTestPost({
id: "saved2",
publishedDate: "2026-01-13", // 2026-W03
// No saves on this one — partial coverage must still sum what exists.
metrics: { impressions: 2000, reactions: 100, comments: 20, shares: 10, clicks: 40, engagementRate: 8.5 },
}),
createTestPost({
id: "saved3",
publishedDate: "2026-01-14", // 2026-W03
metrics: { impressions: 1500, reactions: 75, comments: 15, shares: 7, clicks: 30, engagementRate: 8.47, saves: 30 },
}),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-14" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.summary.totalSaves, 42, "totalSaves should sum the posts that carry saves (12 + 30)");
});
test("should leave totalSaves undefined when no post carries saves (backward-compat)", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({ id: "nosave1", publishedDate: "2026-01-12" }),
createTestPost({ id: "nosave2", publishedDate: "2026-01-13" }),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-13" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.summary.totalSaves, undefined, "Saves-free data must not introduce a totalSaves field");
});
test("should identify top performers and underperformers", () => {
tempDir = setupTempDir();