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
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
3
plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-edge-export.csv
vendored
Normal file
3
plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-edge-export.csv
vendored
Normal 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
|
||||
|
3
plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-export.csv
vendored
Normal file
3
plugins/linkedin-studio/scripts/analytics/tests/fixtures/saves-export.csv
vendored
Normal 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,
|
||||
|
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue