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>
244 lines
7.7 KiB
TypeScript
244 lines
7.7 KiB
TypeScript
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;
|
|
}
|