import type { PostAnalytics, WeeklyReport } from "../models/types.js"; import { mean, trendDirection, percentChange } from "../utils/stats.js"; import { detectAlerts, detectWeeklyAlerts } from "../utils/alerts.js"; import { loadAllPosts, loadWeeklyReport, saveWeeklyReport } from "../utils/storage.js"; /** * Get current ISO week string (e.g., "2026-W05"). * Uses ISO 8601 week date system where Monday is first day of week. */ export function getCurrentISOWeek(): string { return getISOWeek(new Date()); } /** * Get ISO week string for a specific date. * Format: "YYYY-WXX" where XX is zero-padded week number. * * ISO 8601 week date rules: * - Week starts on Monday * - Week 1 is the week with the first Thursday of the year * - Last week of year might extend into next year */ export function getISOWeek(date: Date): string { // Copy date to avoid mutating original const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); // Set to nearest Thursday: current date + 4 - current day number // Make Sunday's day number 7 const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); // Get first day of year const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); // Calculate full weeks to nearest Thursday const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); // Return ISO week format const year = d.getUTCFullYear(); const weekStr = weekNo.toString().padStart(2, '0'); return `${year}-W${weekStr}`; } /** * Filter posts to a specific ISO week. * Posts are matched by converting their publishedDate to ISO week format. */ export function getPostsForWeek(posts: PostAnalytics[], week: string): PostAnalytics[] { return posts.filter(post => { const postDate = new Date(post.publishedDate); const postWeek = getISOWeek(postDate); return postWeek === week; }); } /** * Get the ISO week string for the previous week. * Uses proper ISO week calculation to handle year boundaries correctly. */ function getPreviousWeek(week: string): string { // Parse week string (e.g., "2026-W05") const match = week.match(/^(\d{4})-W(\d{2})$/); if (!match) { throw new Error(`Invalid week format: ${week}`); } const year = parseInt(match[1]); const weekNum = parseInt(match[2]); // ISO week 1 is the week containing January 4th // Find Thursday of the target ISO week const jan4 = new Date(Date.UTC(year, 0, 4)); // Find Monday of week 1 by going back from Jan 4 to Monday const jan4Day = jan4.getUTCDay() || 7; // Sunday = 7 in ISO const week1Monday = new Date(jan4.getTime() - (jan4Day - 1) * 24 * 60 * 60 * 1000); // Add (weekNum - 1) * 7 days to get Monday of target week const targetMonday = new Date(week1Monday.getTime() + (weekNum - 1) * 7 * 24 * 60 * 60 * 1000); // Add 3 days to get Thursday of target week const targetThursday = new Date(targetMonday.getTime() + 3 * 24 * 60 * 60 * 1000); // Subtract 7 days to get previous week's Thursday const previousThursday = new Date(targetThursday.getTime() - 7 * 24 * 60 * 60 * 1000); // Use getISOWeek to get the correct ISO week string return getISOWeek(previousThursday); } /** * Generate a weekly report from imported analytics data. * * @param analyticsRoot - Root directory containing analytics data * @param week - ISO week string (e.g., "2026-W05"). If not provided, uses current week. * @returns WeeklyReport object * * Process: * 1. Load all posts from storage * 2. Filter posts for target week * 3. Calculate summary metrics * 4. Find top 3 performers and bottom 3 underperformers * 5. Calculate trends vs previous week * 6. Generate alerts * 7. Save and return report * * Edge cases: * - No posts for week → zeroed summary * - No previous week data → stable trends with 0% change * - Fewer than 3 posts → shorter top/bottom lists */ export function generateWeeklyReport(analyticsRoot: string, week?: string): WeeklyReport { // Determine target week const targetWeek = week || getCurrentISOWeek(); // Load all posts const allPosts = loadAllPosts(analyticsRoot); // Filter posts for target week const weekPosts = getPostsForWeek(allPosts, targetWeek); // Initialize report structure const report: WeeklyReport = { week: targetWeek, generatedAt: new Date().toISOString(), summary: { totalPosts: weekPosts.length, totalImpressions: 0, totalReactions: 0, totalComments: 0, totalShares: 0, totalClicks: 0, avgEngagementRate: 0, avgImpressionsPerPost: 0, }, topPerformers: [], underperformers: [], trends: { impressionsTrend: "stable", engagementTrend: "stable", comparedTo: getPreviousWeek(targetWeek), percentChange: { impressions: 0, engagement: 0, }, }, alerts: [], }; // If no posts, return early with zeroed report if (weekPosts.length === 0) { return report; } // 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 const engagementRates = weekPosts.map(post => post.metrics.engagementRate); report.summary.avgEngagementRate = mean(engagementRates); report.summary.avgImpressionsPerPost = report.summary.totalImpressions / weekPosts.length; // Find top 3 performers (highest engagement rate) const sortedByEngagement = [...weekPosts].sort( (a, b) => b.metrics.engagementRate - a.metrics.engagementRate ); report.topPerformers = sortedByEngagement.slice(0, 3); // Find bottom 3 underperformers (lowest engagement rate) report.underperformers = sortedByEngagement .slice() .reverse() .slice(0, 3); // Calculate trends vs previous week const previousWeek = getPreviousWeek(targetWeek); const previousReport = loadWeeklyReport(analyticsRoot, previousWeek); if (previousReport && previousReport.summary.totalPosts > 0) { // Calculate percent changes report.trends.percentChange.impressions = percentChange( report.summary.totalImpressions, previousReport.summary.totalImpressions ); report.trends.percentChange.engagement = percentChange( report.summary.avgEngagementRate, previousReport.summary.avgEngagementRate ); // Determine trend directions report.trends.impressionsTrend = trendDirection( report.summary.totalImpressions, previousReport.summary.totalImpressions ); report.trends.engagementTrend = trendDirection( report.summary.avgEngagementRate, previousReport.summary.avgEngagementRate ); } // Generate alerts const postAlerts = detectAlerts(weekPosts, "impressions"); let weeklyAlerts: typeof report.alerts = []; if (previousReport && previousReport.summary.totalPosts > 0) { weeklyAlerts = detectWeeklyAlerts( { impressions: report.summary.totalImpressions, engagementRate: report.summary.avgEngagementRate, }, { impressions: previousReport.summary.totalImpressions, engagementRate: previousReport.summary.avgEngagementRate, } ); } report.alerts = [...weeklyAlerts, ...postAlerts]; // Save report saveWeeklyReport(analyticsRoot, report); return report; }