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(); 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; }