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>
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
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);
|
|
// 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;
|
|
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,
|
|
...(totalSaves !== undefined ? { totalSaves } : {}),
|
|
avgEngagementRate,
|
|
avgImpressionsPerPost,
|
|
},
|
|
topPerformers,
|
|
byWeek,
|
|
trends,
|
|
alerts,
|
|
};
|
|
|
|
// Save report
|
|
saveMonthlyReport(root, report);
|
|
|
|
return report;
|
|
}
|