diff --git a/README.md b/README.md index 3d7ba55..4249924 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,11 @@ Build authentic LinkedIn authority through algorithmic understanding, strategic - **Long-form newsletter pipeline** — a multi-phase orchestrator (research → skeleton → prose → de-AI/voice scrub → fact-check → editorial and persona gates → visual assets → lock → delivery) with maintained edition state - **Adversarial review** — cold headless content, language, and fact reviewers re-check a frozen draft with no drafting-session context; `/linkedin:pivot` re-opens cleared gates on major rewrites -- **Content engine** — Content Matrix (40+ ideas from one topic), voice training with drift detection, full ideation → publish → 48-hour monitoring → analytics +- **Content engine** — Content Matrix (40+ ideas from one topic), voice training with drift detection, full ideation → publish → 48-hour monitoring +- **Honest analytics** — CSV-import pipeline with weekly/monthly reports and anomaly alerts. Adds an + **optional, manually-entered `saves`** count (add a `Saves` column with the number read off native + LinkedIn analytics; never auto-tracked, `unknown` ≠ 0, excluded from the engagement rate). `dwell` + time stays **explicitly unmeasurable** — no fabricated metric or surface - **Growth and monetization** — phase-specific guidance from 0 to 10K+ followers; topic-relevance profile optimization aligned to LinkedIn's 2026 ranking model Key commands: `/linkedin:create` + `/linkedin:measure` (journey front-doors), `/linkedin:onboarding`, `/linkedin:post`, `/linkedin:newsletter`, `/linkedin:report` diff --git a/plugins/linkedin-studio/CHANGELOG.md b/plugins/linkedin-studio/CHANGELOG.md index e04658d..a225a27 100644 --- a/plugins/linkedin-studio/CHANGELOG.md +++ b/plugins/linkedin-studio/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Compatibility - **Minor / additive.** No command removed, renamed, or behavior-changed; the 27 existing commands, all 19 agents, and all state shapes are unchanged. The two new commands (`create`, `measure`) register when the plugin command set is rebuilt at session start — **reload required** to see them. +### Added — within 4.1.0 (refinement sessions, no surface/count/version change) +- **Manual per-post saves in analytics (Voyage S16).** Lifts the original v4.0.0 Non-Goal: `PostMetrics` gains an **optional** `saves` field, ingested when the user adds a `Saves` column to the CSV with the count read off native LinkedIn post analytics (count-only, ~Sept 2025; absent from the export, no self-serve API). The parser (`scripts/analytics/src/parsers/csv-parser.ts`) reads it when present; weekly/monthly summaries gain an optional `totalSaves`; the CLI (`import`/`report`) surfaces saves per-post and as a total. **Backward-compatible** — a missing column or blank cell leaves saves *unknown* (never coerced to 0), saves is **not** folded into `engagementRate` (which stays comparable to older imports), and saves-free data round-trips byte-identical. **Dwell stays explicitly unmeasurable** — no dwell field or surface was added. This refines the v4.0.0 "the plugin cannot read those signals" wording: the plugin still cannot *auto-track* saves, but it now ingests a *manually-entered* count. Built location-agnostically through the existing `getAnalyticsRoot()` seam so the planned data-dir migration (UI brief §9b/M0) relocates it in one place. New `RankableMetric` type fixes the trend/alert index access that the optional field would otherwise widen to `number | undefined`. +- **Onboarding tool-grant fix (S16-pre).** `commands/onboarding.md` Phase 2 saves voice/user-profile files but its frontmatter omitted `Write`; added `Write` to `allowed-tools` (matching `first-post.md`). Closes a pre-existing tool-contract gap surfaced by the S15 review. + ## [4.0.0] - 2026-05-30 ### Summary diff --git a/plugins/linkedin-studio/CLAUDE.md b/plugins/linkedin-studio/CLAUDE.md index 9d89249..a29d74e 100644 --- a/plugins/linkedin-studio/CLAUDE.md +++ b/plugins/linkedin-studio/CLAUDE.md @@ -11,6 +11,17 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide - **Post queue:** `assets/drafts/queue.json` (managed by `hooks/scripts/queue-manager.mjs`) - **Analytics CLI:** `scripts/analytics/` (TypeScript, requires `tsx` and `npm install`) - **Analytics data:** `assets/analytics/` (gitignored) +- **Analytics metrics (S16):** the parsed CSV columns plus an **optional, manually-entered** `saves` count. + Saves are count-only in native LinkedIn post analytics (~Sept 2025), absent from the CSV export, and + have no self-serve API — so the ingest path is the user adding a `Saves` column with the number they + read off LinkedIn. `parseOptionalCount()` parses it when present: blank / non-numeric / negative → + `undefined` (`unknown`, never 0), a genuine `0` is kept, and saves is **not** folded into + `engagementRate` (kept comparable to older imports). Surfaced per-post + as `totalSaves` in the + weekly/monthly reports; **never auto-tracked**. +- **Unmeasured by design:** `dwell` time stays **explicitly unmeasurable** — internal to LinkedIn for + organic posts, no exportable count, no API; no dwell field or surface exists. The S16 analytics + extension routes all I/O through the existing `getAnalyticsRoot()` seam, so the planned per-user + data-dir migration (UI-brief §9b/M0) relocates the root in one place without reworking the schema. ## Hooks diff --git a/plugins/linkedin-studio/README.md b/plugins/linkedin-studio/README.md index a2386aa..b9a9f2b 100644 --- a/plugins/linkedin-studio/README.md +++ b/plugins/linkedin-studio/README.md @@ -437,9 +437,13 @@ profile**. As of 2026-05: Community Management API app + a verified organization + a Page). It is **not self-serve** for a solo personal profile, so the practical floor is the **CSV export** you drop into `/linkedin:import`. Per-post **saves** are visible in your - **native** LinkedIn post analytics (count-only, since ~Sept 2025), but there is - no self-serve API to pull them — this tool does not auto-track them; read them in - LinkedIn directly. + **native** LinkedIn post analytics (count-only, since ~Sept 2025) but are absent + from the CSV export, and there is no self-serve API to pull them. The tool does + **not** auto-track saves — but you can record them **manually**: add a `Saves` + column to the CSV with the counts you read off LinkedIn, and `/linkedin:import` + ingests them and surfaces them in the weekly/monthly reports (omit the column and + saves simply stays unknown — never counted as 0, never folded into engagement + rate). - **Auto-publish** — technically **possible** self-serve via the `w_member_social` scope, so this is **not** an API limitation. LinkedIn Studio **deliberately does not** post for you: the OAuth/token overhead plus LinkedIn's terms on automated diff --git a/plugins/linkedin-studio/assets/analytics/README.md b/plugins/linkedin-studio/assets/analytics/README.md index 4503998..16047b7 100644 --- a/plugins/linkedin-studio/assets/analytics/README.md +++ b/plugins/linkedin-studio/assets/analytics/README.md @@ -9,6 +9,25 @@ This directory contains imported analytics data from LinkedIn CSV exports. 3. Save the CSV file to `exports/` directory 4. Run `/linkedin:import` in Claude Code +### Optional: add per-post saves (manual) + +LinkedIn's CSV export does **not** include saves, and there is no self-serve API +to pull them — but the per-post save **count** is visible in your native post +analytics (since ~Sept 2025). To track it, add a `Saves` column to the CSV and +type the count you read off LinkedIn. The importer picks it up automatically when +the column is present: + +``` +"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Saves" +"My post...",2026-02-10,5000,100,30,15,200,42 +``` + +A missing column — or a blank `Saves` cell — leaves saves **unknown** (never +counted as 0), and saves is **not** folded into the engagement rate (which stays +comparable to older imports). Saves is the strongest organic engagement signal, +so the reports surface it as its own line. **Dwell time stays unmeasurable** — +it is internal to LinkedIn for organic posts, with no count to transcribe. + ## Directory Structure ``` @@ -50,6 +69,10 @@ Each file contains a batch of imported posts: } ``` +`metrics.saves` is **optional** — present only on posts where you supplied a +`Saves` column value (see "Optional: add per-post saves" above). Posts without +it omit the field entirely, so older imports round-trip unchanged. + ### Weekly Reports (weekly-reports/*.json) Generated via `/linkedin:report`. Contains: diff --git a/plugins/linkedin-studio/commands/onboarding.md b/plugins/linkedin-studio/commands/onboarding.md index 39bc712..f262220 100644 --- a/plugins/linkedin-studio/commands/onboarding.md +++ b/plugins/linkedin-studio/commands/onboarding.md @@ -8,6 +8,7 @@ description: | "just installed", "how do I start", "walk me through", "linkedin onboarding". allowed-tools: - Read + - Write - Bash - AskUserQuestion --- diff --git a/plugins/linkedin-studio/docs/remediation/review.md b/plugins/linkedin-studio/docs/remediation/review.md index b64d3a0..b1ade77 100644 --- a/plugins/linkedin-studio/docs/remediation/review.md +++ b/plugins/linkedin-studio/docs/remediation/review.md @@ -1,13 +1,13 @@ --- type: trekreview review_version: "1.0" -task: "S15 — UX finish §6c on the final 29-command set: B1 onboarding inline-draft + B3 carousel full-deck clipboard (B2 router-tiering already delivered in S14)" +task: "S16 — saves manual-entry surface (lifts the original Non-Goal): optional saves in the analytics data model (types + parser + weekly/monthly + CLI), built location-agnostic for M0 (option c); + S16-pre: close the deferred onboarding Write MAJOR" slug: remediation project_dir: docs/remediation/ brief_path: docs/remediation/brief.md -scope_sha_start: baca30f -scope_sha_end: baca30f -reviewed_files_count: 2 +scope_sha_start: 8c52bdb +scope_sha_end: 8c52bdb +reviewed_files_count: 15 verdict: ALLOW mode: default effort: standard @@ -15,129 +15,140 @@ profile: premium findings: [] --- -# Review — linkedin-studio S15 (UX finish §6c: B1 onboarding inline + B3 carousel full-deck) +# Review — linkedin-studio S16 (saves manual-entry + S16-pre onboarding Write) ## Executive Summary -**Verdict: ALLOW** for S15's delivered scope — 0 BLOCKER, 0 MAJOR, 0 MINOR, 0 SUGGESTION -**attributable to the S15 diff**. Two independent reviewers (brief-conformance, -code-correctness) ran COLD, without cross-feeding, on the as-delivered uncommitted -working tree (HEAD `baca30f` + the S15 delta = two command files). +**Verdict: ALLOW** for S16's delivered scope — 0 BLOCKER, 0 MAJOR, 0 MINOR, 0 SUGGESTION +**open** against the diff. Two independent reviewers (brief-conformance, code-correctness) +ran COLD, without cross-feeding, on the as-delivered uncommitted working tree (HEAD +`8c52bdb` + the S16 delta). -- **brief-conformance-reviewer:** 0 findings. B1 and B3 both trace to delivered code; the - binding §12b scope-guard is honored (no extensibility / provider-seam / progressive- - disclosure / detect-and-offer language entered the diff); no version/count drift - (19/29/25/6 intact, v4.1.0 untouched); no files outside the two in scope. -- **code-correctness-reviewer:** 1 MAJOR — but **pre-existing and outside S15's scope** - (see Findings). The S15 changes themselves are correct: branches all terminate, the - state-updater + clipboard snippets mirror the established `first-post.md` pattern - exactly, `${CLAUDE_PLUGIN_ROOT}` is the established path var, and the larger carousel - clipboard payload introduces no new failure mode over the uniform plugin pattern. +- **brief-conformance-reviewer:** **0 findings.** Every Success Criterion traces to delivered + code (SC1 saves in types + parser + import/report; SC2 saves in the actionable-signal output; + SC3 dwell still explicitly unmeasurable, no fabricated field; SC4 backward-compat + no-crash; + SC5 onboarding `Write`). Every Non-Goal honored: **M0 not built** (I/O still routes through the + unmodified `getAnalyticsRoot()` seam — option (c)), dwell not fabricated, surface counts + (29/19/6/9) and version (v4.1.0) unchanged, no not-mine file touched. +- **code-correctness-reviewer:** **2 MAJOR — both in S16's own new code, both FIXED in-session** + (see Findings). Neither is pre-existing; both are lockstep misses in the saves parser delivered + this session, so per the operator rule ("in-session fix of the session's *own* misses = + completion") they were corrected here, not deferred. -S15 = **B1 + B3 only**. B2 (router tiering) was already delivered in S14 (the finish-plan -S15 body still lists B2, but the S14 amendment + the session-state label supersede it); -its absence here is correct, not a gap. +The M0 conflict (UI-brief §9b) was reconciled **before** building, by operator decision = +**option (c): build now, location-agnostic**. The conformance reviewer independently confirmed +the build honors it (no new hardcoded `assets/analytics` path; `storage.ts` untouched). ## Coverage -Scope: HEAD `baca30f` (S14's commit) + the **uncommitted S15 working-tree delta** -(annotated `[uncommitted]` — a brief-level contract; the brief's Assumptions allow -uncommitted review). 2 files = the operator's own changes. The 3 untracked not-mine files -(`docs/linkedin-studio-persona-brief.md`, `…-ui-brief.md`, `docs/voyage-build/progress.json`) -are explicitly excluded from scope and from the commit. **No silent skips.** +Scope: HEAD `8c52bdb` (S15's commit) + the **uncommitted S16 working-tree delta** (annotated +`[uncommitted]` — a brief-level contract; the brief's Assumptions allow uncommitted review). +The 3 untracked not-mine files (`docs/linkedin-studio-persona-brief.md`, `…-ui-brief.md`, +`docs/voyage-build/progress.json`) are explicitly excluded from scope and from the commit. +**No silent skips.** | Treatment | Count | Notes | |-----------|-------|-------| -| `deep-review` | 0 | neither file is under `hooks/**` / `auth/**` / `crypto/**` / `**/security/**` | -| `summary-only` | 2 | `commands/onboarding.md` (Phase 3 rewrite + 1 Phase-4 summary line), `commands/carousel.md` (Step 6 clipboard) | +| `deep-review` | 0 | nothing under `hooks/**` / `auth/**` / `crypto/**` / `**/security/**` (analytics CLI + one command frontmatter) | +| `summary-only` | 15 | analytics src (5) + analytics tests (3) + 2 fixtures + assets/analytics/README.md + README.md + CHANGELOG.md + commands/onboarding.md | | `skip` | 0 | no lockfiles / svg / generated / dist | -**B1/B3 acceptance checks (orchestrator-run greps):** -- B1: `grep -E "[Rr]un \`?/linkedin:first-post" commands/onboarding.md` → **0 dead-end strings**; - inline draft steps 3.1–3.5 present (topic → 3-line draft → QC → present+clipboard → state-update that sets `first_post_date`). -- B3: full-deck assembly block at `carousel.md:191` precedes the `clipboard-helper.mjs` call at `:211`; payload contains slide text; caption-only `` removed. - -**Cross-cutting execution criteria:** `scripts/test-runner.sh` → **74 passed / 0 failed / -0 warnings**, exit 0. `node --test hooks/scripts/__tests__/*.test.mjs` → **98/98** (no hook -logic changed). `ls commands/*.md` → **29** (no surface-count change; no version bump — -consistent with S11–S13 precedent: within-scope refinements stay at the current version). +**Execution criteria (orchestrator-run):** +- `npx tsc --noEmit` (analytics) → **clean** (the new `RankableMetric` type closes the + `metrics[keyof]` widening the optional `saves` would otherwise introduce in `alerts.ts` + `cli.ts`). +- `node --import tsx --test tests/*.test.ts` (analytics) → **116/116**. +- `bash scripts/test-runner.sh` → **74 passed / 0 failed / 0 warnings**, exit 0. +- `node --test hooks/scripts/__tests__/*.test.mjs` → **98/98** (no hook logic changed). +- `ls commands/*.md` → **29** (no surface-count change; no version bump — within-v4.1.0 refinement, S11–S13 precedent). +- **E2E:** import + weekly + monthly with a saves fixture surfaces saves without crashing; + saves-free fixtures round-trip byte-identical; explicit `"0"` → `0`, blank/`"n/a"` → undefined. ## Findings -**0 findings attributable to the S15 diff.** One MAJOR was raised by code-correctness; on -inspection it is **pre-existing and out of S15's scope**, and is routed to the next session -per the finish-plan's locked constraint ("fix-in-next-session for any review finding") and -the operator rule "ekte design-funn → neste sesjon" (in-session fix is reserved for the -session's *own* lockstep misses). It is recorded below and propagated to STATE.md — **not -dropped, not silenced.** +**0 open findings.** Code-correctness raised 2 MAJOR; both are **S16's own** new code (not +pre-existing — the saves parser is new this session), so both were **fixed in-session** as +completion of the delivered work, then re-verified green. Recorded below — not dropped, not +silenced. -### [MAJOR — DEFERRED to next session] onboarding Phase 2 file-saves need `Write`, not in `allowed-tools` +### [MAJOR — FIXED in-session] Non-numeric Saves cell silently coerced to 0 (`unknown != 0` contract violated) -*Raised by code-correctness (`PLAN_EXECUTE_DRIFT`), `commands/onboarding.md:142` (and `:157`).* +*Raised by code-correctness (`PLAN_EXECUTE_DRIFT`), `scripts/analytics/src/parsers/csv-parser.ts`.* -`onboarding.md` frontmatter grants `allowed-tools: [Read, Bash, AskUserQuestion]` — no -`Write`. **Phase 2** instructs "Save the responses to `assets/voice-samples/authentic-voice-samples.md`" -(`:142`, with a REPLACE-vs-append rule) and "Save to `config/user-profile.local.md`" (`:157`). -Persisting those profile files is a file write; the plugin's accepted pattern is the `Write` -tool, and the sibling `first-post.md` (same voice-save) declares `Write` (`first-post.md:9-14`). -With only Read/Bash/AskUserQuestion the agent cannot honor the two "Save to …" instructions -(the only scripted Bash writes are the `node -e` state and clipboard snippets). **Real defect.** +The original guard `if (savesRaw && savesRaw.trim() !== "")` then `metrics.saves = +parseMetric(savesRaw)` gated only on emptiness; `parseMetric` ends in `parseFloat(...) || 0` ++ `Math.max(0, …)`, so a non-empty non-numeric cell (`"n/a"`, `"~40"`) or a negative value +flattened to **0** — surfaced as a confident "top engagement signal". This contradicts the +`unknown != 0` contract stated in the same diff (`types.ts` saves NOTE + the parser comment). -**Why DEFERRED, not fixed in S15:** -- **Pre-existing.** The gap exists identically at HEAD `baca30f` *before* the S15 edits — - `git show HEAD:…/onboarding.md` shows `allowed-tools: [Read, Bash, AskUserQuestion]` already. -- **Out of S15's diff scope.** The S15 hunks are `@@ -166` (Phase 3) and `@@ -201` (Phase 4 - summary line); lines 142/157 (Phase 2) are untouched. S15's *own* inline steps (3.1–3.5) - stay within Read/Bash/AskUserQuestion and need no `Write` (3.4 clipboard = Bash, 3.5 - state-update = Bash `node -e`). -- **Not introduced or worsened by S15.** The reviewer's "now owns the save flow" framing does - not hold on inspection: Phase 2's saves are reached identically before and after S15, and - the removed `/linkedin:first-post` hand-off was about *post creation* (Phase 3), not Phase 2's - profile saves. S15 added no save-flow that needs `Write`. -- **Operator governance.** Fixing a Phase-2 tool-contract bug inside an S15 (B1+B3) push would - be exactly the "rydd opp i pre-existing som del av en bugfix" / scope-expansion the operator's - rules forbid; the documented default for out-of-scope items is **defer** (record, route to - next session) — not silently fix, not silently drop. +**Fix (this session):** added a dedicated `parseOptionalCount()` helper returning +`number | undefined` — blank / non-numeric / negative → `undefined`; a genuine number +(including explicit `"0"`) → that number; reuses the EU/US separator normalization. The saves +parse now routes through it. The blank-cell path is unchanged; the garbage/negative path now +honors the contract. -**Recommended action (next session):** add `Write` to `onboarding.md`'s `allowed-tools` -(matching `first-post.md:9-14`), or replace the two "Save to …" instructions with a `node -e` -Bash write so the steps stay within the declared Bash grant. Natural home: fold into S16 (which -already opens the onboarding/saves surface) or the S17 triage pass. +### [MAJOR — FIXED in-session] No test for the explicit-`0` vs non-numeric Saves boundary + +*Raised by code-correctness (`MISSING_TEST`), `scripts/analytics/tests/csv-parser.test.ts`.* + +The original saves tests covered `42`, blank→undefined, no-column→undefined, and +engagement-exclusion — but not the exact boundary where the parser's behavior diverged from +its intent: a literal `"0"` (genuine zero) vs a non-numeric cell (unknown). + +**Fix (this session):** added `tests/fixtures/saves-edge-export.csv` (a `"0"` row + an `"n/a"` +row) and two cases pinning `saves === 0` for `"0"` and `saves === undefined` for non-numeric. +The non-numeric case was **red** before the parser fix above and **green** after (TDD), so the +contract boundary is now regression-guarded. + +### [MAJOR — CLOSED] (carried from S15) onboarding Phase 2 file-saves need `Write` + +*S15's deferred finding.* `commands/onboarding.md` Phase 2 saves voice/user-profile files but +the frontmatter omitted `Write`. **Closed in S16-pre:** `Write` added to `allowed-tools` +(matching `first-post.md:9-14`). ## Remediation Summary -**Gate: ALLOW** for S15's delivered scope (B1 + B3). Both reviewers confirm the two changes -are conformant (brief) and correct (code); the binding §12b scope-guard held; no version/count -drift; the inline first-post flow and the full-deck clipboard both function within the existing -tool grants and established patterns. The single MAJOR is a **pre-existing, out-of-scope** -tool-contract gap in Phase 2 that S15 neither introduced nor worsened; per the operator's -explicit "ekte design-funn → neste sesjon" rule it is **deferred and recorded**, not fixed -as scope creep. This is a genuine ALLOW of the scoped delivery — **not** a WARN-override (there -is no open finding *against* S15's diff). +**Gate: ALLOW** for S16's delivered scope. brief-conformance is clean; code-correctness's two +MAJORs were S16's own lockstep misses (not pre-existing design findings), so both were fixed +in-session and re-verified (tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98) — this is a +genuine ALLOW with **no open finding**, not a WARN-override. The M0 conflict was reconciled +before building (option c, location-agnostic); the conformance reviewer confirmed M0 was not +built and the data-dir seam (`getAnalyticsRoot()`) is untouched, so the planned migration +relocates the root in one place. The S15-deferred onboarding `Write` MAJOR is closed. -Per Handover 6, this `review.md` is consumable by `/trekplan --brief …`. With an ALLOW verdict -on the delivered scope and the one deferred finding routed forward, S15 may commit + push, and -the finish-plan continues at S16 (with the deferred `Write` gap reconciled there or in S17). +Per Handover 6, this `review.md` is consumable by `/trekplan --brief …`. ALLOW → S16 commits + +pushes (own files only); the finish-plan continues at **S17** (C13–C46 triage). ```json { "verdict": "ALLOW", - "verdict_scope": "S15 delivered changes (B1 + B3); 2 files", - "scope": { "sha_start": "baca30f", "sha_end": "baca30f", "reviewed_files_count": 2, "uncommitted_delta": true }, + "verdict_scope": "S16 delivered changes (saves manual-entry + S16-pre onboarding Write); 15 files", + "scope": { "sha_start": "8c52bdb", "sha_end": "8c52bdb", "reviewed_files_count": 15, "uncommitted_delta": true }, "counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 }, "findings": [], - "deferred_findings": [ + "fixed_in_session": [ { "severity": "MAJOR", - "title": "onboarding Phase 2 file-saves need Write, not in allowed-tools", - "file": "commands/onboarding.md", - "line": 142, + "title": "Non-numeric Saves cell silently coerced to 0 (unknown != 0 contract)", + "file": "scripts/analytics/src/parsers/csv-parser.ts", "rule_key": "PLAN_EXECUTE_DRIFT", - "status": "pre-existing, out of S15 scope (Phase 2; S15 diff = Phase 3 + Phase 4 line)", - "routed_to": "next session (S16 onboarding/saves surface, or S17 triage)", - "recommended_action": "add Write to onboarding.md allowed-tools (match first-post.md:9-14), or replace the two 'Save to ...' instructions with a node -e Bash write" + "resolution": "added parseOptionalCount() returning number|undefined (blank/non-numeric/negative -> undefined; genuine 0 kept); saves parse routes through it" + }, + { + "severity": "MAJOR", + "title": "No test for explicit-0 vs non-numeric Saves boundary", + "file": "scripts/analytics/tests/csv-parser.test.ts", + "rule_key": "MISSING_TEST", + "resolution": "added saves-edge-export.csv fixture + 2 cases (0 -> 0, n/a -> undefined); TDD red-before/green-after the parser fix" + }, + { + "severity": "MAJOR", + "title": "onboarding Phase 2 file-saves need Write (carried from S15)", + "file": "commands/onboarding.md", + "rule_key": "PLAN_EXECUTE_DRIFT", + "resolution": "S16-pre: added Write to allowed-tools, matching first-post.md" } ], + "deferred_findings": [], "dropped_findings": [] } ``` diff --git a/plugins/linkedin-studio/scripts/analytics/src/cli.ts b/plugins/linkedin-studio/scripts/analytics/src/cli.ts index c6c1e1b..2024815 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/cli.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/cli.ts @@ -12,7 +12,7 @@ import { generateHeatmap } from "./reports/heatmap.js"; import { generateMonthlyReport } from "./reports/monthly.js"; import { join } from "node:path"; import { existsSync } from "node:fs"; -import type { PostMetrics } from "./models/types.js"; +import type { RankableMetric } from "./models/types.js"; const args = process.argv.slice(2); const command = args[0]; @@ -22,6 +22,14 @@ function parseOption(args: string[], flag: string): string | undefined { return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined; } +/** + * Per-post saves suffix for report lines. Empty string when the post carries no + * manual saves data, so saves-free output stays identical to the pre-saves CLI. + */ +function savesSuffix(saves?: number): string { + return saves !== undefined ? ` | ${saves.toLocaleString()} saves` : ""; +} + function printUsage() { console.log(` LinkedIn Analytics CLI @@ -75,6 +83,13 @@ async function handleImport(root: string, args: string[]) { console.log(`Batch ID: ${batch.batchId}`); console.log(`Saved to: posts/${savedFilename}`); + // Surface manually-entered saves when the CSV carried a Saves column. + const savesPosts = batch.posts.filter((p) => p.metrics.saves !== undefined); + if (savesPosts.length > 0) { + const totalSaves = savesPosts.reduce((sum, p) => sum + (p.metrics.saves ?? 0), 0); + console.log(`Saves entered: ${totalSaves.toLocaleString()} across ${savesPosts.length} post(s) (manual)`); + } + // Run alert detection on imported posts const alerts = detectAlerts(batch.posts, "impressions"); @@ -126,6 +141,9 @@ async function handleReport(root: string, args: string[]) { console.log(`Total comments: ${report.summary.totalComments.toLocaleString()}`); console.log(`Total shares: ${report.summary.totalShares.toLocaleString()}`); console.log(`Total clicks: ${report.summary.totalClicks.toLocaleString()}`); + if (report.summary.totalSaves !== undefined) { + console.log(`Total saves: ${report.summary.totalSaves.toLocaleString()} (manual entry — top engagement signal)`); + } console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`); console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`); console.log(); @@ -136,7 +154,7 @@ async function handleReport(root: string, args: string[]) { for (const post of report.topPerformers.slice(0, 5)) { const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title; console.log(`• ${title}`); - console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`); + console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`); } console.log(); } @@ -147,7 +165,7 @@ async function handleReport(root: string, args: string[]) { for (const post of report.underperformers.slice(0, 3)) { const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title; console.log(`• ${title}`); - console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`); + console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`); } console.log(); } @@ -177,10 +195,11 @@ async function handleReport(root: string, args: string[]) { } /** - * Type guard to check if a string is a valid PostMetrics key + * Type guard to check if a string is a rankable (always-numeric) metric key. + * Excludes the optional, manually-entered `saves` — it is not a trend metric. */ -function isPostMetric(value: string): value is keyof PostMetrics { - const validMetrics: (keyof PostMetrics)[] = [ +function isPostMetric(value: string): value is RankableMetric { + const validMetrics: RankableMetric[] = [ "impressions", "reactions", "comments", @@ -188,7 +207,7 @@ function isPostMetric(value: string): value is keyof PostMetrics { "clicks", "engagementRate", ]; - return validMetrics.includes(value as keyof PostMetrics); + return validMetrics.includes(value as RankableMetric); } async function handleTrends(root: string, args: string[]) { @@ -203,7 +222,7 @@ async function handleTrends(root: string, args: string[]) { } if (!isPostMetric(metricOption)) { - const validMetrics: (keyof PostMetrics)[] = [ + const validMetrics: RankableMetric[] = [ "impressions", "reactions", "comments", @@ -320,6 +339,9 @@ async function handleMonthlyReport(root: string, month: string) { console.log(`Comments: ${s.totalComments.toLocaleString()}`); console.log(`Shares: ${s.totalShares.toLocaleString()}`); console.log(`Clicks: ${s.totalClicks.toLocaleString()}`); + if (s.totalSaves !== undefined) { + console.log(`Saves: ${s.totalSaves.toLocaleString()} (manual entry — top engagement signal)`); + } console.log(); if (report.byWeek.length > 0) { @@ -337,7 +359,7 @@ async function handleMonthlyReport(root: string, month: string) { for (const post of report.topPerformers.slice(0, 5)) { const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title; console.log(`• ${title}`); - console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng | ${post.publishedDate}`); + console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`); } console.log(); } diff --git a/plugins/linkedin-studio/scripts/analytics/src/models/types.ts b/plugins/linkedin-studio/scripts/analytics/src/models/types.ts index d85ba5c..c9ed27f 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/models/types.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/models/types.ts @@ -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; }; diff --git a/plugins/linkedin-studio/scripts/analytics/src/parsers/csv-parser.ts b/plugins/linkedin-studio/scripts/analytics/src/parsers/csv-parser.ts index 9edbbd5..229b21c 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/parsers/csv-parser.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/parsers/csv-parser.ts @@ -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, diff --git a/plugins/linkedin-studio/scripts/analytics/src/reports/monthly.ts b/plugins/linkedin-studio/scripts/analytics/src/reports/monthly.ts index 571abdd..db9f965 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/reports/monthly.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/reports/monthly.ts @@ -28,6 +28,12 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor const totalComments = monthPosts.reduce((s, p) => s + p.metrics.comments, 0); const totalShares = monthPosts.reduce((s, p) => s + p.metrics.shares, 0); const totalClicks = monthPosts.reduce((s, p) => s + p.metrics.clicks, 0); + // Optional saves: present only when ≥1 post carries manual saves data — + // keeps saves-free months byte-identical to pre-saves output (backward-compat). + const savesPosts = monthPosts.filter(p => p.metrics.saves !== undefined); + const totalSaves = savesPosts.length > 0 + ? savesPosts.reduce((s, p) => s + (p.metrics.saves ?? 0), 0) + : undefined; const avgEngagementRate = totalPosts > 0 ? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2)) : 0; @@ -101,6 +107,7 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor totalComments, totalShares, totalClicks, + ...(totalSaves !== undefined ? { totalSaves } : {}), avgEngagementRate, avgImpressionsPerPost, }, diff --git a/plugins/linkedin-studio/scripts/analytics/src/reports/weekly.ts b/plugins/linkedin-studio/scripts/analytics/src/reports/weekly.ts index f1cc857..52c9901 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/reports/weekly.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/reports/weekly.ts @@ -154,12 +154,23 @@ export function generateWeeklyReport(analyticsRoot: string, week?: string): Week } // Calculate summary metrics + let totalSaves = 0; + let sawSaves = false; for (const post of weekPosts) { report.summary.totalImpressions += post.metrics.impressions; report.summary.totalReactions += post.metrics.reactions; report.summary.totalComments += post.metrics.comments; report.summary.totalShares += post.metrics.shares; report.summary.totalClicks += post.metrics.clicks; + if (post.metrics.saves !== undefined) { + totalSaves += post.metrics.saves; + sawSaves = true; + } + } + // Only surface saves when at least one post carried it — keeps saves-free + // reports byte-identical to pre-saves output (backward-compat). + if (sawSaves) { + report.summary.totalSaves = totalSaves; } // Calculate averages diff --git a/plugins/linkedin-studio/scripts/analytics/src/utils/alerts.ts b/plugins/linkedin-studio/scripts/analytics/src/utils/alerts.ts index a845713..6b2aa29 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/utils/alerts.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/utils/alerts.ts @@ -1,7 +1,7 @@ import type { PostAnalytics, Alert, - PostMetrics, + RankableMetric, } from "../models/types.js"; import { ALERT_THRESHOLDS } from "../models/types.js"; import { @@ -17,7 +17,7 @@ import { */ export function detectAlerts( posts: PostAnalytics[], - metricKey: keyof PostMetrics = "impressions" + metricKey: RankableMetric = "impressions" ): Alert[] { if (posts.length === 0) return []; diff --git a/plugins/linkedin-studio/scripts/analytics/tests/csv-parser.test.ts b/plugins/linkedin-studio/scripts/analytics/tests/csv-parser.test.ts index 318e90d..efc8833 100644 --- a/plugins/linkedin-studio/scripts/analytics/tests/csv-parser.test.ts +++ b/plugins/linkedin-studio/scripts/analytics/tests/csv-parser.test.ts @@ -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" + ); + }); +}); diff --git a/plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-edge-export.csv b/plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-edge-export.csv new file mode 100644 index 0000000..68b514d --- /dev/null +++ b/plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-edge-export.csv @@ -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 diff --git a/plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-export.csv b/plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-export.csv new file mode 100644 index 0000000..243f80c --- /dev/null +++ b/plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-export.csv @@ -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, diff --git a/plugins/linkedin-studio/scripts/analytics/tests/monthly.test.ts b/plugins/linkedin-studio/scripts/analytics/tests/monthly.test.ts index 3ca5f53..9f2d107 100644 --- a/plugins/linkedin-studio/scripts/analytics/tests/monthly.test.ts +++ b/plugins/linkedin-studio/scripts/analytics/tests/monthly.test.ts @@ -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"); diff --git a/plugins/linkedin-studio/scripts/analytics/tests/weekly.test.ts b/plugins/linkedin-studio/scripts/analytics/tests/weekly.test.ts index 1551a86..dbb5d94 100644 --- a/plugins/linkedin-studio/scripts/analytics/tests/weekly.test.ts +++ b/plugins/linkedin-studio/scripts/analytics/tests/weekly.test.ts @@ -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();