import { aggregateByCategory, countChecked, groupByMonth, monthKey, readLog } from "./store.ts"; import { loadTaxonomy, type TaxonomyCategory } from "./taxonomy.ts"; export type TrendDirection = "improving" | "regressing" | "unchanged"; export interface CategoryTrend { direction: TrendDirection; deltaRate: number; } export interface CategoryReportRow { category: string; label: string; count: number; rate: number; trend?: CategoryTrend; } export interface ReportFilters { since?: string; } export type ReportOutcome = | { kind: "no-data" } | { kind: "filtered-empty" } | { kind: "ok"; rows: CategoryReportRow[]; messagesChecked: number; skipped: number }; function rateOf(count: number, checked: number): number { return checked > 0 ? (count / checked) * 100 : 0; } const TREND_EPSILON = 0.01; function trendFor(currentRate: number, previousRate: number): CategoryTrend { const deltaRate = currentRate - previousRate; const direction: TrendDirection = deltaRate < -TREND_EPSILON ? "improving" : deltaRate > TREND_EPSILON ? "regressing" : "unchanged"; return { direction, deltaRate }; } /** * Builds the ranked, rate-normalized report. Trend markers compare the two most * recent calendar months present in the filtered data, independent of how far * back `since` reaches, so a longer window still yields a meaningful trend. */ export function buildReport( logPath: string, filters: ReportFilters = {}, taxonomy: TaxonomyCategory[] = loadTaxonomy(), ): ReportOutcome { const { records, skipped } = readLog(logPath); if (records.length === 0) return { kind: "no-data" }; const filtered = records.filter((r) => { if (filters.since && monthKey(r.ts) < filters.since) return false; return true; }); if (filtered.length === 0) return { kind: "filtered-empty" }; const labelByKey = new Map(taxonomy.map((t) => [t.key, t.label])); const reportableKeys = new Set(taxonomy.filter((t) => t.reportable).map((t) => t.key)); const messagesChecked = countChecked(filtered); const categoryCounts = aggregateByCategory(filtered).filter((c) => reportableKeys.has(c.category)); const monthGroups = groupByMonth(filtered); const months = [...monthGroups.keys()].sort(); const latestMonth = months[months.length - 1]; const previousMonth = months.length > 1 ? months[months.length - 2] : undefined; let previousRates: Map | undefined; let currentMonthRates: Map | undefined; if (previousMonth) { const currentRecords = monthGroups.get(latestMonth) ?? []; const currentChecked = countChecked(currentRecords); currentMonthRates = new Map(aggregateByCategory(currentRecords).map((c) => [c.category, rateOf(c.count, currentChecked)])); const prevRecords = monthGroups.get(previousMonth) ?? []; const prevChecked = countChecked(prevRecords); previousRates = new Map(aggregateByCategory(prevRecords).map((c) => [c.category, rateOf(c.count, prevChecked)])); } const rows: CategoryReportRow[] = categoryCounts .map((c) => { const rate = rateOf(c.count, messagesChecked); const trend = previousRates && currentMonthRates ? trendFor(currentMonthRates.get(c.category) ?? 0, previousRates.get(c.category) ?? 0) : undefined; return { category: c.category, label: labelByKey.get(c.category) ?? c.category, count: c.count, rate, trend }; }) .sort((a, b) => b.count - a.count); return { kind: "ok", rows, messagesChecked, skipped }; } const TREND_SYMBOL: Record = { improving: "↓", regressing: "↑", unchanged: "→", }; export function formatReport(outcome: ReportOutcome): string { if (outcome.kind === "no-data") { return "No English-tutor data yet. Nothing to set up — the log fills in as you write, and this report becomes useful once you've sent a few messages."; } if (outcome.kind === "filtered-empty") { return "No records match that filter."; } const lines = [`Messages checked: ${outcome.messagesChecked}`, ""]; for (const row of outcome.rows) { const rate = row.rate.toFixed(1); const trend = row.trend ? ` ${TREND_SYMBOL[row.trend.direction]} ${row.trend.direction} (${row.trend.deltaRate.toFixed(1)})` : ""; lines.push(`${row.label}: ${row.count} (${rate} / 100 msgs)${trend}`); } return lines.join("\n"); }