feat(linkedin-thought-leadership): v1.1.0 — Q2 2026 feature release
9 improvements across 3 tracks: Onboarding: /linkedin:onboarding wizard, README Quick Start rewrite Content Quality: voice drift scoring, industry angle variants, /linkedin:carousel, /linkedin:react multi-URL comparison Analytics: automated week-rollover, day-of-week heatmap, month-over-month reports 25→27 commands. All Q2 ROADMAP items completed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abf7322200
commit
1a8cc1942c
33 changed files with 1726 additions and 236 deletions
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateHeatmap } from "../src/reports/heatmap.js";
|
||||
import type { PostAnalytics } from "../src/models/types.js";
|
||||
|
||||
function createPost(date: string, impressions: number, engagementRate: number): PostAnalytics {
|
||||
return {
|
||||
id: `post-${date}`,
|
||||
title: `Post on ${date}`,
|
||||
publishedDate: date,
|
||||
metrics: {
|
||||
impressions,
|
||||
reactions: 10,
|
||||
comments: 5,
|
||||
shares: 2,
|
||||
clicks: 3,
|
||||
engagementRate,
|
||||
},
|
||||
importedAt: "2026-04-01T00:00:00Z",
|
||||
exportSource: "test.csv",
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateHeatmap", () => {
|
||||
// Verified days: 2026-04-06=Mon, 07=Tue, 08=Wed, 09=Thu, 12=Sun, 13=Mon, 14=Tue, 15=Wed
|
||||
const posts: PostAnalytics[] = [
|
||||
createPost("2026-04-06", 1000, 3.0), // Monday
|
||||
createPost("2026-04-07", 2000, 4.0), // Tuesday
|
||||
createPost("2026-04-08", 1500, 3.5), // Wednesday
|
||||
createPost("2026-04-13", 3000, 5.0), // Monday
|
||||
createPost("2026-04-14", 2500, 4.5), // Tuesday
|
||||
createPost("2026-04-12", 800, 2.0), // Sunday
|
||||
];
|
||||
|
||||
test("groups posts by day of week correctly", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday");
|
||||
const tuesday = report.byDayOfWeek.find(d => d.dayName === "Tuesday");
|
||||
const sunday = report.byDayOfWeek.find(d => d.dayName === "Sunday");
|
||||
|
||||
assert.equal(monday?.postCount, 2);
|
||||
assert.equal(tuesday?.postCount, 2);
|
||||
assert.equal(sunday?.postCount, 1);
|
||||
});
|
||||
|
||||
test("calculates correct averages per day", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday")!;
|
||||
const tuesday = report.byDayOfWeek.find(d => d.dayName === "Tuesday")!;
|
||||
|
||||
assert.equal(monday.avgImpressions, 2000); // (1000+3000)/2
|
||||
assert.equal(tuesday.avgImpressions, 2250); // (2000+2500)/2
|
||||
assert.equal(monday.avgEngagementRate, 4.0); // (3.0+5.0)/2
|
||||
});
|
||||
|
||||
test("handles days with no posts", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const friday = report.byDayOfWeek.find(d => d.dayName === "Friday")!;
|
||||
|
||||
assert.equal(friday.postCount, 0);
|
||||
assert.equal(friday.avgImpressions, 0);
|
||||
assert.equal(friday.avgEngagementRate, 0);
|
||||
});
|
||||
|
||||
test("returns 7 entries ordered Mon-Sun", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.byDayOfWeek.length, 7);
|
||||
assert.equal(report.byDayOfWeek[0].dayName, "Monday");
|
||||
assert.equal(report.byDayOfWeek[6].dayName, "Sunday");
|
||||
assert.deepEqual(
|
||||
report.byDayOfWeek.map(d => d.dayIndex),
|
||||
[1, 2, 3, 4, 5, 6, 7]
|
||||
);
|
||||
});
|
||||
|
||||
test("identifies best day for impressions", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.bestDayImpressions, "Tuesday");
|
||||
});
|
||||
|
||||
test("identifies best day for engagement", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.bestDayEngagement, "Tuesday"); // (4.0+4.5)/2 = 4.25
|
||||
});
|
||||
|
||||
test("sets correct postsAnalyzed count", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.postsAnalyzed, 6);
|
||||
});
|
||||
|
||||
test("handles empty post list", () => {
|
||||
const report = generateHeatmap([]);
|
||||
assert.equal(report.postsAnalyzed, 0);
|
||||
assert.equal(report.byDayOfWeek.length, 7);
|
||||
assert.equal(report.bestDayImpressions, "N/A");
|
||||
assert.equal(report.bestDayEngagement, "N/A");
|
||||
for (const day of report.byDayOfWeek) {
|
||||
assert.equal(day.postCount, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("identifies best post per day", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday")!;
|
||||
assert.equal(monday.bestPost?.publishedDate, "2026-04-13"); // 3000 impressions
|
||||
});
|
||||
|
||||
test("calculates correct date range", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.dateRange.from, "2026-04-06");
|
||||
assert.equal(report.dateRange.to, "2026-04-14");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { generateMonthlyReport } from "../src/reports/monthly.js";
|
||||
import { saveBatch } from "../src/utils/storage.js";
|
||||
import type { PostAnalytics, AnalyticsBatch, MonthlyReport } from "../src/models/types.js";
|
||||
|
||||
function createPost(date: string, impressions: number, engagementRate: number): PostAnalytics {
|
||||
return {
|
||||
id: `post-${date}-${impressions}`,
|
||||
title: `Post on ${date}`,
|
||||
publishedDate: date,
|
||||
metrics: {
|
||||
impressions,
|
||||
reactions: Math.round(impressions * 0.05),
|
||||
comments: Math.round(impressions * 0.01),
|
||||
shares: Math.round(impressions * 0.005),
|
||||
clicks: Math.round(impressions * 0.02),
|
||||
engagementRate,
|
||||
},
|
||||
importedAt: "2026-04-01T00:00:00Z",
|
||||
exportSource: "test.csv",
|
||||
};
|
||||
}
|
||||
|
||||
function createBatch(posts: PostAnalytics[]): AnalyticsBatch {
|
||||
const dates = posts.map(p => p.publishedDate).sort();
|
||||
return {
|
||||
batchId: "test-batch-" + Math.random().toString(36).slice(2, 10),
|
||||
importedAt: "2026-04-01T00:00:00Z",
|
||||
exportFilename: "test.csv",
|
||||
dateRange: { from: dates[0], to: dates[dates.length - 1] },
|
||||
postCount: posts.length,
|
||||
posts,
|
||||
};
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function setupTestRoot(posts: PostAnalytics[]): string {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "monthly-test-"));
|
||||
for (const dir of ["exports", "posts", "weekly-reports", "monthly-reports"]) {
|
||||
mkdirSync(join(tmpDir, dir), { recursive: true });
|
||||
}
|
||||
if (posts.length > 0) {
|
||||
saveBatch(tmpDir, createBatch(posts));
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("generateMonthlyReport", () => {
|
||||
const marchPosts: PostAnalytics[] = [
|
||||
createPost("2026-03-03", 1000, 3.0),
|
||||
createPost("2026-03-05", 2000, 4.0),
|
||||
createPost("2026-03-10", 1500, 3.5),
|
||||
createPost("2026-03-17", 3000, 5.0),
|
||||
createPost("2026-03-24", 2500, 4.5),
|
||||
];
|
||||
|
||||
const febPosts: PostAnalytics[] = [
|
||||
createPost("2026-02-03", 800, 2.5),
|
||||
createPost("2026-02-10", 1200, 3.0),
|
||||
createPost("2026-02-17", 900, 2.8),
|
||||
];
|
||||
|
||||
test("filters posts to correct month", () => {
|
||||
const root = setupTestRoot([...marchPosts, ...febPosts]);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.summary.totalPosts, 5);
|
||||
});
|
||||
|
||||
test("calculates correct monthly totals", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.summary.totalImpressions, 10000); // 1000+2000+1500+3000+2500
|
||||
assert.equal(report.summary.totalPosts, 5);
|
||||
assert.equal(report.summary.avgImpressionsPerPost, 2000);
|
||||
});
|
||||
|
||||
test("generates weekly breakdown within month", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.ok(report.byWeek.length > 0);
|
||||
const totalPostsInWeeks = report.byWeek.reduce((sum, w) => sum + w.postCount, 0);
|
||||
assert.equal(totalPostsInWeeks, 5);
|
||||
});
|
||||
|
||||
test("calculates MoM deltas when previous month exists", () => {
|
||||
const root = setupTestRoot([...febPosts, ...marchPosts]);
|
||||
// First generate February report so it exists for comparison
|
||||
generateMonthlyReport(root, "2026-02");
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
|
||||
assert.notEqual(report.trends.comparedTo, null);
|
||||
assert.equal(report.trends.comparedTo, "2026-02");
|
||||
assert.notEqual(report.trends.percentChange.impressions, null);
|
||||
assert.notEqual(report.trends.percentChange.postCount, null);
|
||||
});
|
||||
|
||||
test("handles no previous month data", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.trends.comparedTo, null);
|
||||
assert.equal(report.trends.percentChange.impressions, null);
|
||||
assert.equal(report.trends.percentChange.engagement, null);
|
||||
});
|
||||
|
||||
test("handles month with no posts", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-01");
|
||||
assert.equal(report.summary.totalPosts, 0);
|
||||
assert.equal(report.summary.totalImpressions, 0);
|
||||
assert.equal(report.summary.avgImpressionsPerPost, 0);
|
||||
assert.equal(report.byWeek.length, 0);
|
||||
});
|
||||
|
||||
test("identifies top performers", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.ok(report.topPerformers.length > 0);
|
||||
assert.equal(report.topPerformers[0].metrics.impressions, 3000);
|
||||
});
|
||||
|
||||
test("sets correct month field", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.month, "2026-03");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue