import { getDb } from "./db"; import type { MonthlyTrendItem, CategoryBreakdownItem, CategoryOverTimeData, CategoryOverTimeItem, HighlightsData, HighlightMover, CategoryDelta, CategoryZoomData, CategoryZoomChild, CategoryZoomEvolutionPoint, MonthBalance, RecentTransaction, } from "../shared/types"; export async function getMonthlyTrends( dateFrom?: string, dateTo?: string, sourceId?: number, ): Promise { const db = await getDb(); const whereClauses: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (dateFrom) { whereClauses.push(`date >= $${paramIndex}`); params.push(dateFrom); paramIndex++; } if (dateTo) { whereClauses.push(`date <= $${paramIndex}`); params.push(dateTo); paramIndex++; } if (sourceId != null) { whereClauses.push(`source_id = $${paramIndex}`); params.push(sourceId); paramIndex++; } const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; return db.select( `SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses FROM transactions ${whereSQL} GROUP BY month ORDER BY month ASC`, params ); } export async function getCategoryOverTime( dateFrom?: string, dateTo?: string, topN: number = 50, sourceId?: number, typeFilter?: "expense" | "income" | "transfer", ): Promise { const db = await getDb(); const whereClauses: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (typeFilter) { whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`); params.push(typeFilter); paramIndex++; } if (dateFrom) { whereClauses.push(`t.date >= $${paramIndex}`); params.push(dateFrom); paramIndex++; } if (dateTo) { whereClauses.push(`t.date <= $${paramIndex}`); params.push(dateTo); paramIndex++; } if (sourceId != null) { whereClauses.push(`t.source_id = $${paramIndex}`); params.push(sourceId); paramIndex++; } const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; // Get top N categories by total spend const topCategories = await db.select( `SELECT t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.color, '#9ca3af') AS category_color, ABS(SUM(t.amount)) AS total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id ${whereSQL} GROUP BY t.category_id ORDER BY total DESC LIMIT $${paramIndex}`, [...params, topN] ); const topCategoryIds = new Set(topCategories.map((c) => c.category_id)); const colors: Record = {}; const categoryIds: Record = {}; for (const cat of topCategories) { colors[cat.category_name] = cat.category_color; categoryIds[cat.category_name] = cat.category_id; } // Get monthly breakdown for all categories const monthlyRows = await db.select< Array<{ month: string; category_id: number | null; category_name: string; total: number; }> >( `SELECT strftime('%Y-%m', t.date) AS month, t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, ABS(SUM(t.amount)) AS total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id ${whereSQL} GROUP BY month, t.category_id ORDER BY month ASC`, params ); // Build pivot data const monthMap = new Map(); let hasOther = false; for (const row of monthlyRows) { if (!monthMap.has(row.month)) { monthMap.set(row.month, { month: row.month }); } const item = monthMap.get(row.month)!; if (topCategoryIds.has(row.category_id)) { item[row.category_name] = ((item[row.category_name] as number) || 0) + row.total; } else { item["Other"] = ((item["Other"] as number) || 0) + row.total; hasOther = true; } } if (hasOther) { colors["Other"] = "#9ca3af"; } const categories = topCategories.map((c) => c.category_name); if (hasOther) { categories.push("Other"); } return { categories, data: Array.from(monthMap.values()), colors, categoryIds, }; } // --- Highlights (Issue #71) --- /** * Shifts a YYYY-MM-DD date string by `months` months and returns the first day * of the resulting month as YYYY-MM-01. Used to compute the start of the * 12-month sparkline window relative to the reference date. */ function shiftMonthStart(refIso: string, months: number): string { const [y, m] = refIso.split("-").map(Number); const d = new Date(Date.UTC(y, m - 1 + months, 1)); const yy = d.getUTCFullYear(); const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); return `${yy}-${mm}-01`; } function shiftDate(refIso: string, days: number): string { const [y, m, d] = refIso.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d + days)); const yy = dt.getUTCFullYear(); const mm = String(dt.getUTCMonth() + 1).padStart(2, "0"); const dd = String(dt.getUTCDate()).padStart(2, "0"); return `${yy}-${mm}-${dd}`; } function monthEnd(yyyyMm: string): string { const [y, m] = yyyyMm.split("-").map(Number); const d = new Date(Date.UTC(y, m, 0)); // day 0 of next month = last day of this month const dd = String(d.getUTCDate()).padStart(2, "0"); return `${yyyyMm}-${dd}`; } /** * Returns the dashboard "highlights" snapshot for the reports hub: * - net balance for the reference month * - YTD net balance * - last 12 months of net balances (for sparkline) * - top movers (biggest change in spending vs previous month) * - top transactions (biggest absolute amounts in last `windowDays` days) * * All SQL is parameterised. `referenceDate` defaults to today and is overridable * from tests for deterministic fixtures. */ export async function getHighlights( windowDays: number = 30, referenceDate?: string, topMoversLimit: number = 5, topTransactionsLimit: number = 10, ): Promise { const db = await getDb(); const refIso = referenceDate ?? (() => { const t = new Date(); return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}-${String(t.getDate()).padStart(2, "0")}`; })(); const currentMonth = refIso.slice(0, 7); // YYYY-MM const currentYear = refIso.slice(0, 4); const yearStart = `${currentYear}-01-01`; const currentMonthStart = `${currentMonth}-01`; const currentMonthEnd = monthEnd(currentMonth); const previousMonthStart = shiftMonthStart(refIso, -1); const previousMonth = previousMonthStart.slice(0, 7); const previousMonthEnd = monthEnd(previousMonth); const sparklineStart = shiftMonthStart(refIso, -11); // 11 months back + current = 12 const recentWindowStart = shiftDate(refIso, -(windowDays - 1)); // 1. Net balance for current month const currentBalanceRows = await db.select>( `SELECT COALESCE(SUM(amount), 0) AS net FROM transactions WHERE date >= $1 AND date <= $2`, [currentMonthStart, currentMonthEnd], ); const netBalanceCurrent = Number(currentBalanceRows[0]?.net ?? 0); // 2. YTD balance const ytdRows = await db.select>( `SELECT COALESCE(SUM(amount), 0) AS net FROM transactions WHERE date >= $1 AND date <= $2`, [yearStart, refIso], ); const netBalanceYtd = Number(ytdRows[0]?.net ?? 0); // 3. 12-month sparkline series const seriesRows = await db.select>( `SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(amount), 0) AS net FROM transactions WHERE date >= $1 AND date <= $2 GROUP BY month ORDER BY month ASC`, [sparklineStart, currentMonthEnd], ); const seriesMap = new Map(seriesRows.map((r) => [r.month, Number(r.net ?? 0)])); const monthlyBalanceSeries: MonthBalance[] = []; for (let i = 11; i >= 0; i--) { const monthKey = shiftMonthStart(refIso, -i).slice(0, 7); monthlyBalanceSeries.push({ month: monthKey, netBalance: seriesMap.get(monthKey) ?? 0 }); } // 4. Top movers — expense-side only (amount < 0), compare current vs previous month const moversRows = await db.select< Array<{ category_id: number | null; category_name: string; category_color: string; current_total: number | null; previous_total: number | null; }> >( `SELECT t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total, COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE t.amount < 0 AND ( (t.date >= $1 AND t.date <= $2) OR (t.date >= $3 AND t.date <= $4) ) GROUP BY t.category_id, category_name, category_color ORDER BY ABS(current_total - previous_total) DESC LIMIT $5`, [currentMonthStart, currentMonthEnd, previousMonthStart, previousMonthEnd, topMoversLimit], ); const topMovers: HighlightMover[] = moversRows.map((r) => { const current = Number(r.current_total ?? 0); const previous = Number(r.previous_total ?? 0); const deltaAbs = current - previous; const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100; return { categoryId: r.category_id, categoryName: r.category_name, categoryColor: r.category_color, previousAmount: previous, currentAmount: current, deltaAbs, deltaPct, }; }); // 5. Top transactions within the recent window const recentRows = await db.select( `SELECT t.id, t.date, t.description, t.amount, c.name AS category_name, c.color AS category_color FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE t.date >= $1 AND t.date <= $2 ORDER BY ABS(t.amount) DESC LIMIT $3`, [recentWindowStart, refIso, topTransactionsLimit], ); return { currentMonth, netBalanceCurrent, netBalanceYtd, monthlyBalanceSeries, topMovers, topTransactions: recentRows, }; } // --- Compare (Issue #73) --- interface RawDeltaRow { category_id: number | null; category_name: string; category_color: string; current_total: number | null; previous_total: number | null; } function rowsToDeltas(rows: RawDeltaRow[]): CategoryDelta[] { return rows.map((r) => { const current = Number(r.current_total ?? 0); const previous = Number(r.previous_total ?? 0); const deltaAbs = current - previous; const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100; return { categoryId: r.category_id, categoryName: r.category_name, categoryColor: r.category_color, previousAmount: previous, currentAmount: current, deltaAbs, deltaPct, }; }); } function monthBoundaries(year: number, month: number): { start: string; end: string } { const mm = String(month).padStart(2, "0"); const endDate = new Date(Date.UTC(year, month, 0)); const dd = String(endDate.getUTCDate()).padStart(2, "0"); return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; } function previousMonth(year: number, month: number): { year: number; month: number } { if (month === 1) return { year: year - 1, month: 12 }; return { year, month: month - 1 }; } /** * Month-over-month expense delta by category. All SQL parameterised. */ export async function getCompareMonthOverMonth( year: number, month: number, ): Promise { const db = await getDb(); const { start: curStart, end: curEnd } = monthBoundaries(year, month); const prev = previousMonth(year, month); const { start: prevStart, end: prevEnd } = monthBoundaries(prev.year, prev.month); const rows = await db.select( `SELECT t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total, COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE t.amount < 0 AND ( (t.date >= $1 AND t.date <= $2) OR (t.date >= $3 AND t.date <= $4) ) GROUP BY t.category_id, category_name, category_color ORDER BY ABS(current_total - previous_total) DESC`, [curStart, curEnd, prevStart, prevEnd], ); return rowsToDeltas(rows); } /** * Year-over-year expense delta by category. All SQL parameterised. */ export async function getCompareYearOverYear(year: number): Promise { const db = await getDb(); const curStart = `${year}-01-01`; const curEnd = `${year}-12-31`; const prevStart = `${year - 1}-01-01`; const prevEnd = `${year - 1}-12-31`; const rows = await db.select( `SELECT t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total, COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE t.amount < 0 AND ( (t.date >= $1 AND t.date <= $2) OR (t.date >= $3 AND t.date <= $4) ) GROUP BY t.category_id, category_name, category_color ORDER BY ABS(current_total - previous_total) DESC`, [curStart, curEnd, prevStart, prevEnd], ); return rowsToDeltas(rows); } // --- Category zoom (Issue #74) --- /** * Recursive CTE fragment bounded to `depth < 5` so a corrupted `parent_id` * loop (A → B → A) can never spin forever. The depth budget is intentionally * low: real category trees rarely exceed 3 levels. */ const CATEGORY_TREE_CTE = ` WITH RECURSIVE cat_tree(id, depth) AS ( SELECT id, 0 FROM categories WHERE id = $1 UNION ALL SELECT c.id, ct.depth + 1 FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id WHERE ct.depth < 5 ) `; /** * Returns the zoom-on-category report for the given category id. * * When `includeSubcategories` is true the recursive CTE walks down the * category tree (capped at 5 levels) and aggregates matching rows. When * false, only direct transactions on `categoryId` are considered. Every * query is parameterised; the category id is never interpolated. */ export async function getCategoryZoom( categoryId: number, dateFrom: string, dateTo: string, includeSubcategories: boolean = true, ): Promise { const db = await getDb(); const categoryFilter = includeSubcategories ? `${CATEGORY_TREE_CTE} SELECT t.id, t.date, t.description, t.amount, t.category_id FROM transactions t WHERE t.category_id IN (SELECT id FROM cat_tree) AND t.date >= $2 AND t.date <= $3` : `SELECT t.id, t.date, t.description, t.amount, t.category_id FROM transactions t WHERE t.category_id = $1 AND t.date >= $2 AND t.date <= $3`; // 1. Transactions (with join for display) const txRows = await db.select( `${includeSubcategories ? CATEGORY_TREE_CTE : ""} SELECT t.id, t.date, t.description, t.amount, c.name AS category_name, c.color AS category_color FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE ${includeSubcategories ? "t.category_id IN (SELECT id FROM cat_tree)" : "t.category_id = $1"} AND t.date >= $2 AND t.date <= $3 ORDER BY ABS(t.amount) DESC LIMIT 500`, [categoryId, dateFrom, dateTo], ); // 2. Rollup total (sum of absolute amounts of matching rows) const totalRows = await db.select>( `${categoryFilter.replace("SELECT t.id, t.date, t.description, t.amount, t.category_id", "SELECT COALESCE(SUM(ABS(t.amount)), 0) AS rollup")}`, [categoryId, dateFrom, dateTo], ); const rollupTotal = Number(totalRows[0]?.rollup ?? 0); // 3. Breakdown by direct child (only meaningful when includeSubcategories is true) const byChild: CategoryZoomChild[] = []; if (includeSubcategories) { const childRows = await db.select< Array<{ child_id: number; child_name: string; child_color: string; total: number | null }> >( `SELECT child.id AS child_id, child.name AS child_name, COALESCE(child.color, '#9ca3af') AS child_color, COALESCE(SUM(ABS(t.amount)), 0) AS total FROM categories child LEFT JOIN transactions t ON t.category_id = child.id AND t.date >= $2 AND t.date <= $3 WHERE child.parent_id = $1 GROUP BY child.id, child.name, child.color ORDER BY total DESC`, [categoryId, dateFrom, dateTo], ); for (const r of childRows) { byChild.push({ categoryId: r.child_id, categoryName: r.child_name, categoryColor: r.child_color, total: Number(r.total ?? 0), }); } } // 4. Monthly evolution across the window const evolutionRows = await db.select>( `${includeSubcategories ? CATEGORY_TREE_CTE : ""} SELECT strftime('%Y-%m', t.date) AS month, COALESCE(SUM(ABS(t.amount)), 0) AS total FROM transactions t WHERE ${includeSubcategories ? "t.category_id IN (SELECT id FROM cat_tree)" : "t.category_id = $1"} AND t.date >= $2 AND t.date <= $3 GROUP BY month ORDER BY month ASC`, [categoryId, dateFrom, dateTo], ); const monthlyEvolution: CategoryZoomEvolutionPoint[] = evolutionRows.map((r) => ({ month: r.month, total: Number(r.total ?? 0), })); return { rollupTotal, byChild, monthlyEvolution, transactions: txRows, }; }