FRE-605: Implement Phase 4 Change Tracking & Merge Logic
- Create ChangeTracker class with full version history support - Document change recording with metadata - Snapshot creation and restoration - Change acceptance/rejection workflow - Change diff generation between snapshots - Event-based change notifications - Implement MergeLogic with screenplay-specific rules - Server change application with conflict detection - Auto-resolution for non-overlapping edits - Scene-aware merge rules (same-scene vs different-scene) - Manual conflict resolution workflow - Merge validation - Write comprehensive unit tests - Change recording and tracking tests - Snapshot management tests - Conflict resolution tests - Screenplay-specific merge rule tests - Document implementation in analysis/fre605_change_tracking_implementation.md Architecture: ChangeTracker integrates with Yjs document updates. MergeLogic applies screenplay-specific rules for concurrent edits. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
257
src/lib/analytics/report-generator.ts
Normal file
257
src/lib/analytics/report-generator.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { eq, and, gte, lte, desc } from "drizzle-orm";
|
||||
import { scheduledReports, kpiSnapshots } from "../../db/schema";
|
||||
import type { DrizzleDB } from "../../db/config/migrations";
|
||||
import type { NewScheduledReport, ScheduledReport } from "../../db/schema";
|
||||
import { getAllLatestKPIs, getKPIHistory, getKPIStatus, type KPIKey } from "./kpi-service";
|
||||
|
||||
export interface ReportData {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
kpis: Record<string, { value: number; status: "healthy" | "warning" | "critical"; change: number }>;
|
||||
alerts: string[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export async function generateWeeklyReport(db: DrizzleDB): Promise<ReportData> {
|
||||
const now = new Date();
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const kpis = await getAllLatestKPIs(db);
|
||||
const kpiData: ReportData["kpis"] = {};
|
||||
|
||||
for (const [key, snapshot] of Object.entries(kpis)) {
|
||||
if (!snapshot) {
|
||||
kpiData[key] = { value: 0, status: "warning", change: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
const history = await getKPIHistory(db, key as KPIKey, weekAgo, now);
|
||||
const previousValue = history.length > 1 ? history[history.length - 2]?.kpiValue ?? snapshot.kpiValue : snapshot.kpiValue;
|
||||
const change = previousValue !== 0 ? ((snapshot.kpiValue - previousValue) / previousValue) * 100 : 0;
|
||||
const status = getKPIStatus(key as KPIKey, snapshot.kpiValue);
|
||||
|
||||
kpiData[key] = { value: snapshot.kpiValue, status, change };
|
||||
}
|
||||
|
||||
const alertMessages = Object.entries(kpiData)
|
||||
.filter(([, data]) => data.status !== "healthy")
|
||||
.map(([key, data]) => `⚠️ ${key}: ${data.value.toFixed(2)} (${data.status})`);
|
||||
|
||||
const healthyCount = Object.values(kpiData).filter((d) => d.status === "healthy").length;
|
||||
const totalKPIs = Object.keys(kpiData).length;
|
||||
|
||||
const summary = `Weekly Report (${weekAgo.toISOString().split("T")[0]} - ${now.toISOString().split("T")[0]})\n${healthyCount}/${totalKPIs} KPIs healthy. ${alertMessages.length > 0 ? "Alerts: " + alertMessages.join(", ") : "No alerts."}`;
|
||||
|
||||
return {
|
||||
periodStart: weekAgo,
|
||||
periodEnd: now,
|
||||
kpis: kpiData,
|
||||
alerts: alertMessages,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMonthlyReport(db: DrizzleDB): Promise<ReportData> {
|
||||
const now = new Date();
|
||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const kpis = await getAllLatestKPIs(db);
|
||||
const kpiData: ReportData["kpis"] = {};
|
||||
|
||||
for (const [key, snapshot] of Object.entries(kpis)) {
|
||||
if (!snapshot) {
|
||||
kpiData[key] = { value: 0, status: "warning", change: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
const history = await getKPIHistory(db, key as KPIKey, monthAgo, now);
|
||||
const previousValue = history.length > 1 ? history[history.length - 2]?.kpiValue ?? snapshot.kpiValue : snapshot.kpiValue;
|
||||
const change = previousValue !== 0 ? ((snapshot.kpiValue - previousValue) / previousValue) * 100 : 0;
|
||||
const status = getKPIStatus(key as KPIKey, snapshot.kpiValue);
|
||||
|
||||
kpiData[key] = { value: snapshot.kpiValue, status, change };
|
||||
}
|
||||
|
||||
const alertMessages = Object.entries(kpiData)
|
||||
.filter(([, data]) => data.status !== "healthy")
|
||||
.map(([key, data]) => `⚠️ ${key}: ${data.value.toFixed(2)} (${data.status})`);
|
||||
|
||||
const healthyCount = Object.values(kpiData).filter((d) => d.status === "healthy").length;
|
||||
const totalKPIs = Object.keys(kpiData).length;
|
||||
|
||||
const summary = `Monthly Report (${monthAgo.toISOString().split("T")[0]} - ${now.toISOString().split("T")[0]})\n${healthyCount}/${totalKPIs} KPIs healthy.`;
|
||||
|
||||
return {
|
||||
periodStart: monthAgo,
|
||||
periodEnd: now,
|
||||
kpis: kpiData,
|
||||
alerts: alertMessages,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function formatReportMarkdown(report: ReportData): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# KPI Report`);
|
||||
lines.push(``);
|
||||
lines.push(`**Period:** ${report.periodStart.toISOString().split("T")[0]} → ${report.periodEnd.toISOString().split("T")[0]}`);
|
||||
lines.push(``);
|
||||
|
||||
lines.push(`## Summary`);
|
||||
lines.push(``);
|
||||
lines.push(report.summary);
|
||||
lines.push(``);
|
||||
|
||||
lines.push(`## KPI Details`);
|
||||
lines.push(``);
|
||||
lines.push(`| KPI | Value | Status | Change |`);
|
||||
lines.push(`|-----|-------|--------|--------|`);
|
||||
|
||||
for (const [key, data] of Object.entries(report.kpis)) {
|
||||
const statusIcon = data.status === "healthy" ? "✅" : data.status === "warning" ? "⚠️" : "🔴";
|
||||
const changeStr = data.change >= 0 ? `+${data.change.toFixed(1)}%` : `${data.change.toFixed(1)}%`;
|
||||
lines.push(`| ${key} | ${data.value.toFixed(2)} | ${statusIcon} ${data.status} | ${changeStr} |`);
|
||||
}
|
||||
|
||||
if (report.alerts.length > 0) {
|
||||
lines.push(``);
|
||||
lines.push(`## Alerts`);
|
||||
lines.push(``);
|
||||
for (const alert of report.alerts) {
|
||||
lines.push(`- ${alert}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function formatReportSlackBlocks(report: ReportData): Promise<unknown[]> {
|
||||
const blocks: unknown[] = [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: `📊 KPI Report`,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*Period:* ${report.periodStart.toISOString().split("T")[0]} → ${report.periodEnd.toISOString().split("T")[0]}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const [key, data] of Object.entries(report.kpis)) {
|
||||
const statusIcon = data.status === "healthy" ? "✅" : data.status === "warning" ? "⚠️" : "🔴";
|
||||
const changeStr = data.change >= 0 ? `+${data.change.toFixed(1)}%` : `${data.change.toFixed(1)}%`;
|
||||
blocks.push({
|
||||
type: "section",
|
||||
fields: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*${key}:*\n${data.value.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Status:*\n${statusIcon} ${data.status}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Change:*\n${changeStr}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export async function createScheduledReport(
|
||||
db: DrizzleDB,
|
||||
input: Omit<NewScheduledReport, "lastRunAt" | "nextRunAt">
|
||||
): Promise<ScheduledReport> {
|
||||
const result = await db.insert(scheduledReports).values({
|
||||
...input,
|
||||
lastRunAt: null,
|
||||
nextRunAt: computeNextRun(input.schedule),
|
||||
}).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getActiveScheduledReports(
|
||||
db: DrizzleDB
|
||||
): Promise<ScheduledReport[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(scheduledReports)
|
||||
.where(eq(scheduledReports.isActive, true))
|
||||
.orderBy(scheduledReports.nextRunAt);
|
||||
}
|
||||
|
||||
export async function runDueReports(db: DrizzleDB): Promise<ScheduledReport[]> {
|
||||
const now = new Date();
|
||||
const dueReports = await db
|
||||
.select()
|
||||
.from(scheduledReports)
|
||||
.where(and(
|
||||
eq(scheduledReports.isActive, true),
|
||||
lte(scheduledReports.nextRunAt, now)
|
||||
));
|
||||
|
||||
const runResults: ScheduledReport[] = [];
|
||||
|
||||
for (const report of dueReports) {
|
||||
let reportData: ReportData;
|
||||
|
||||
switch (report.reportType) {
|
||||
case "weekly_kpi":
|
||||
reportData = await generateWeeklyReport(db);
|
||||
break;
|
||||
case "monthly_kpi":
|
||||
reportData = await generateMonthlyReport(db);
|
||||
break;
|
||||
default:
|
||||
reportData = await generateWeeklyReport(db);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(scheduledReports)
|
||||
.set({
|
||||
lastRunAt: now,
|
||||
nextRunAt: computeNextRun(report.schedule),
|
||||
})
|
||||
.where(eq(scheduledReports.id, report.id));
|
||||
|
||||
runResults.push(report);
|
||||
}
|
||||
|
||||
return runResults;
|
||||
}
|
||||
|
||||
function computeNextRun(schedule: string): Date {
|
||||
const now = new Date();
|
||||
|
||||
switch (schedule) {
|
||||
case "weekly":
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
nextWeek.setHours(9, 0, 0, 0);
|
||||
return nextWeek;
|
||||
case "monthly":
|
||||
const nextMonth = new Date(now);
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1);
|
||||
nextMonth.setHours(9, 0, 0, 0);
|
||||
return nextMonth;
|
||||
case "daily":
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
return tomorrow;
|
||||
default:
|
||||
return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user