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
e36efe9944
commit
69fec21f43
17 changed files with 412 additions and 117 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue