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:
parent
8c52bdb2e4
commit
55c94ee964
18 changed files with 417 additions and 118 deletions
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue