feat(ultraplan-local): v1.6.0 — /ultraresearch-local deep research command
Add /ultraresearch-local for structured research combining local codebase analysis with external knowledge via parallel agent swarms. Produces research briefs with triangulation, confidence ratings, and source quality assessment. New command: /ultraresearch-local with modes --quick, --local, --external, --fg. New agents: research-orchestrator (opus), docs-researcher, community-researcher, security-researcher, contrarian-researcher, gemini-bridge (all sonnet). New template: research-brief-template.md. Integration: --research flag in /ultraplan-local accepts pre-built research briefs (up to 3), enriches the interview and exploration phases. Planning orchestrator cross-references brief findings during synthesis. Design principle: Context Engineering — right information to right agent at right time. Research briefs are structured artifacts in the pipeline: ultraresearch → brief → ultraplan --research → plan → ultraexecute. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
4dc868ea62
151 changed files with 34374 additions and 0 deletions
85
scripts/analytics/src/reports/heatmap.ts
Normal file
85
scripts/analytics/src/reports/heatmap.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
117
scripts/analytics/src/reports/monthly.ts
Normal file
117
scripts/analytics/src/reports/monthly.ts
Normal 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;
|
||||
}
|
||||
233
scripts/analytics/src/reports/weekly.ts
Normal file
233
scripts/analytics/src/reports/weekly.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
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
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue