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

@ -122,3 +122,72 @@ describe("CSV Parser", () => {
assert.equal(batch.dateRange.to, "2026-01-28", "Date range to should be latest date");
});
});
describe("Saves (manual-entry, optional)", () => {
it("should parse a Saves column when the user augments the CSV with it", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
assert.equal(batch.postCount, 2, "Should have 2 posts");
// Row 1 carries a saves count read from native LinkedIn analytics.
assert.equal(batch.posts[0].metrics.saves, 42, "Should parse the Saves cell value");
});
it("should leave saves undefined when the Saves cell is blank (unknown != zero)", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
// Row 2's Saves cell is empty — saves is unknown, NOT zero.
assert.equal(
batch.posts[1].metrics.saves,
undefined,
"Blank Saves cell must stay undefined, never coerced to 0"
);
});
it("should leave saves undefined for a standard export with no Saves column (backward-compat)", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
for (const post of batch.posts) {
assert.equal(
post.metrics.saves,
undefined,
"Existing CSV exports without a Saves column must round-trip unchanged"
);
}
});
it("should NOT fold saves into engagementRate (kept comparable to historical data)", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
// Row 1: (100+30+15+200)/5000 * 100 = 6.9 — saves (42) must NOT be in the numerator.
const expectedRate = ((100 + 30 + 15 + 200) / 5000) * 100;
assert.ok(
Math.abs(batch.posts[0].metrics.engagementRate - expectedRate) < 0.01,
`engagementRate should exclude saves (~${expectedRate}), got ${batch.posts[0].metrics.engagementRate}`
);
});
it("should treat an explicit '0' Saves cell as a genuine zero (not undefined)", () => {
const filePath = join(fixturesDir, "saves-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-edge-export.csv");
// A literal 0 in the Saves column is a real reading — zero saves, not unknown.
assert.equal(batch.posts[0].metrics.saves, 0, "Explicit '0' must stay 0, not collapse to undefined");
});
it("should leave saves undefined for a non-numeric Saves cell (unknown, never coerced to 0)", () => {
const filePath = join(fixturesDir, "saves-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-edge-export.csv");
// "n/a" is not a count — saves stays unknown, NOT silently flattened to 0.
assert.equal(
batch.posts[1].metrics.saves,
undefined,
"Non-numeric Saves cell must stay undefined — never coerced to 0"
);
});
});

View file

@ -0,0 +1,3 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Saves"
"Explicit zero saves — a real reading of zero, must stay 0 not undefined...",2026-02-12,4000,80,25,10,150,0
"Non-numeric saves cell — the user jotted a note, not a count; stays unknown...",2026-02-11,3500,70,22,9,130,n/a
1 Content Date Impressions Reactions Comments Shares Clicks Saves
2 Explicit zero saves — a real reading of zero, must stay 0 not undefined... 2026-02-12 4000 80 25 10 150 0
3 Non-numeric saves cell — the user jotted a note, not a count; stays unknown... 2026-02-11 3500 70 22 9 130 n/a

View file

@ -0,0 +1,3 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Saves"
"A save-worthy framework post the user augmented with the native saves count...",2026-02-10,5000,100,30,15,200,42
"A post where the user left the Saves cell blank — unknown, not zero...",2026-02-09,3000,60,20,8,120,
1 Content Date Impressions Reactions Comments Shares Clicks Saves
2 A save-worthy framework post the user augmented with the native saves count... 2026-02-10 5000 100 30 15 200 42
3 A post where the user left the Saves cell blank — unknown, not zero... 2026-02-09 3000 60 20 8 120

View file

@ -83,6 +83,27 @@ describe("generateMonthlyReport", () => {
assert.equal(report.summary.avgImpressionsPerPost, 2000);
});
test("sums saves into totalSaves when posts carry manual saves data", () => {
const withSaves = (p: PostAnalytics, saves: number): PostAnalytics => ({
...p,
metrics: { ...p.metrics, saves },
});
const posts: PostAnalytics[] = [
withSaves(createPost("2026-03-03", 1000, 3.0), 8),
createPost("2026-03-05", 2000, 4.0), // no saves — partial coverage
withSaves(createPost("2026-03-10", 1500, 3.5), 13),
];
const root = setupTestRoot(posts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalSaves, 21, "totalSaves should sum 8 + 13");
});
test("leaves totalSaves undefined for saves-free months (backward-compat)", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalSaves, undefined);
});
test("generates weekly breakdown within month", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");

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();