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:
Kjell Tore Guttormsen 2026-04-08 06:16:35 +02:00
commit 1a8cc1942c
33 changed files with 1726 additions and 236 deletions

View file

@ -0,0 +1,85 @@
import type { PostAnalytics, DayOfWeekMetrics, HeatmapReport } from "../models/types.js";
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
// Convert JS getDay() (0=Sun) to ISO weekday (1=Mon, 7=Sun)
function toISOWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay;
}
/**
* Generate a day-of-week performance heatmap from post analytics data.
* Groups posts by day of week and calculates average metrics per day.
*/
export function generateHeatmap(posts: PostAnalytics[]): HeatmapReport {
// Initialize buckets for all 7 days (ISO: 1=Mon to 7=Sun)
const buckets: Map<number, PostAnalytics[]> = new Map();
for (let i = 1; i <= 7; i++) {
buckets.set(i, []);
}
// Group posts by ISO weekday
for (const post of posts) {
const jsDay = new Date(post.publishedDate).getUTCDay();
const isoDay = toISOWeekday(jsDay);
buckets.get(isoDay)!.push(post);
}
// Build metrics per day
const byDayOfWeek: DayOfWeekMetrics[] = [];
for (let isoDay = 1; isoDay <= 7; isoDay++) {
const dayPosts = buckets.get(isoDay)!;
const jsDay = isoDay === 7 ? 0 : isoDay;
const dayName = DAY_NAMES[jsDay];
if (dayPosts.length === 0) {
byDayOfWeek.push({
dayName,
dayIndex: isoDay,
postCount: 0,
avgImpressions: 0,
avgEngagementRate: 0,
});
continue;
}
const totalImpressions = dayPosts.reduce((sum, p) => sum + p.metrics.impressions, 0);
const totalEngagement = dayPosts.reduce((sum, p) => sum + p.metrics.engagementRate, 0);
const bestPost = dayPosts.reduce((best, p) =>
p.metrics.impressions > best.metrics.impressions ? p : best
);
byDayOfWeek.push({
dayName,
dayIndex: isoDay,
postCount: dayPosts.length,
avgImpressions: Math.round(totalImpressions / dayPosts.length),
avgEngagementRate: parseFloat((totalEngagement / dayPosts.length).toFixed(1)),
bestPost,
});
}
// Find best days
const daysWithPosts = byDayOfWeek.filter(d => d.postCount > 0);
const bestDayImpressions = daysWithPosts.length > 0
? daysWithPosts.reduce((best, d) => d.avgImpressions > best.avgImpressions ? d : best).dayName
: "N/A";
const bestDayEngagement = daysWithPosts.length > 0
? daysWithPosts.reduce((best, d) => d.avgEngagementRate > best.avgEngagementRate ? d : best).dayName
: "N/A";
// Date range
const sortedDates = posts.map(p => p.publishedDate).sort();
const dateRange = posts.length > 0
? { from: sortedDates[0], to: sortedDates[sortedDates.length - 1] }
: { from: "", to: "" };
return {
generatedAt: new Date().toISOString(),
postsAnalyzed: posts.length,
dateRange,
byDayOfWeek,
bestDayImpressions,
bestDayEngagement,
};
}

View file

@ -0,0 +1,117 @@
import type { PostAnalytics, MonthlyReport } from "../models/types.js";
import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js";
import { mean } from "../utils/stats.js";
import { detectAlerts } from "../utils/alerts.js";
import { getISOWeek } from "./weekly.js";
/**
* Get previous month string (e.g., "2026-03" "2026-02")
*/
function getPreviousMonth(month: string): string {
const [year, m] = month.split("-").map(Number);
if (m === 1) return `${year - 1}-12`;
return `${year}-${String(m - 1).padStart(2, "0")}`;
}
/**
* Generate a monthly report with optional MoM comparison.
* Saves the report to disk and returns it.
*/
export function generateMonthlyReport(root: string, month: string): MonthlyReport {
const allPosts = loadAllPosts(root);
const monthPosts = allPosts.filter(p => p.publishedDate.startsWith(month));
// Summary
const totalPosts = monthPosts.length;
const totalImpressions = monthPosts.reduce((s, p) => s + p.metrics.impressions, 0);
const totalReactions = monthPosts.reduce((s, p) => s + p.metrics.reactions, 0);
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);
const avgEngagementRate = totalPosts > 0
? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2))
: 0;
const avgImpressionsPerPost = totalPosts > 0
? Math.round(totalImpressions / totalPosts)
: 0;
// Top performers (sorted by impressions desc)
const topPerformers = [...monthPosts]
.sort((a, b) => b.metrics.impressions - a.metrics.impressions)
.slice(0, 5);
// Weekly breakdown
const weekBuckets = new Map<string, PostAnalytics[]>();
for (const post of monthPosts) {
const week = getISOWeek(new Date(post.publishedDate + "T00:00:00Z"));
if (!weekBuckets.has(week)) weekBuckets.set(week, []);
weekBuckets.get(week)!.push(post);
}
const byWeek = Array.from(weekBuckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([week, posts]) => ({
week,
postCount: posts.length,
avgImpressions: Math.round(mean(posts.map(p => p.metrics.impressions))),
avgEngagementRate: parseFloat(mean(posts.map(p => p.metrics.engagementRate)).toFixed(1)),
}));
// MoM comparison
const prevMonth = getPreviousMonth(month);
const prevReport = loadMonthlyReport(root, prevMonth);
let trends: MonthlyReport["trends"];
if (prevReport && prevReport.summary.totalPosts > 0) {
const pctImpr = prevReport.summary.totalImpressions > 0
? parseFloat(((totalImpressions - prevReport.summary.totalImpressions) / prevReport.summary.totalImpressions * 100).toFixed(1))
: null;
const pctEng = prevReport.summary.avgEngagementRate > 0
? parseFloat(((avgEngagementRate - prevReport.summary.avgEngagementRate) / prevReport.summary.avgEngagementRate * 100).toFixed(1))
: null;
const pctPosts = prevReport.summary.totalPosts > 0
? parseFloat(((totalPosts - prevReport.summary.totalPosts) / prevReport.summary.totalPosts * 100).toFixed(1))
: null;
trends = {
comparedTo: prevMonth,
percentChange: {
impressions: pctImpr,
engagement: pctEng,
postCount: pctPosts,
},
};
} else {
trends = {
comparedTo: null,
percentChange: { impressions: null, engagement: null, postCount: null },
};
}
// Alerts
const alerts = totalPosts > 0 ? detectAlerts(monthPosts, "impressions") : [];
const report: MonthlyReport = {
month,
generatedAt: new Date().toISOString(),
summary: {
totalPosts,
totalImpressions,
totalReactions,
totalComments,
totalShares,
totalClicks,
avgEngagementRate,
avgImpressionsPerPost,
},
topPerformers,
byWeek,
trends,
alerts,
};
// Save report
saveMonthlyReport(root, report);
return report;
}