Simpl-Resultat/src/services/reportService.ts
le king fu bd8a5732c6
All checks were successful
PR Check / rust (push) Successful in 21m21s
PR Check / frontend (push) Successful in 2m11s
PR Check / rust (pull_request) Successful in 21m30s
PR Check / frontend (pull_request) Successful in 2m8s
feat(reports/compare): 8-column table with monthly + cumulative YTD blocks (#104)
Mirror the BudgetVsActualTable structure in the Actual-vs-Actual compare
mode so MoM and YoY both surface a Monthly block (reference month vs
comparison month) and a Cumulative YTD block (progress through the
reference month vs progress through the previous window).

- CategoryDelta gains cumulative{Previous,Current}Amount and
  cumulativeDelta{Abs,Pct}. Legacy previousAmount / currentAmount /
  deltaAbs / deltaPct are kept as aliases of the monthly block so the
  Highlights hub, Cartes dashboard and ComparePeriodChart keep working
  unchanged.
- getCompareMonthOverMonth: cumulative-previous window ends at the end
  of the previous month within the SAME year; when the reference month
  is January, the previous window sits entirely in the prior calendar
  year (Jan → Dec).
- getCompareYearOverYear: now takes an optional reference month
  (defaults to December for backward compatibility). Monthly block
  compares the single reference month across years; cumulative block
  compares Jan → refMonth across years.
- ComparePeriodTable rebuilt with two colspan header groups, four
  sub-columns each, a totals row and month/year boundary sub-labels.
- ComparePeriodChart unchanged: still reads the monthly primary fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:17:32 -04:00

966 lines
33 KiB
TypeScript

import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService";
import type {
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
HighlightsData,
HighlightMover,
CategoryDelta,
CategoryZoomData,
CategoryZoomChild,
CategoryZoomEvolutionPoint,
MonthBalance,
RecentTransaction,
CartesSnapshot,
CartesKpi,
CartesSparklinePoint,
CartesTopMover,
CartesMonthFlow,
CartesBudgetAdherence,
CartesBudgetWorstOverrun,
CartesSeasonality,
CartesSeasonalityYear,
} from "../shared/types";
export async function getMonthlyTrends(
dateFrom?: string,
dateTo?: string,
sourceId?: number,
): Promise<MonthlyTrendItem[]> {
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<MonthlyTrendItem[]>(
`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<CategoryOverTimeData> {
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<CategoryBreakdownItem[]>(
`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<string, string> = {};
const categoryIds: Record<string, number | null> = {};
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<string, CategoryOverTimeItem>();
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<HighlightsData> {
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<Array<{ net: number | null }>>(
`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<Array<{ net: number | null }>>(
`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<Array<{ month: string; net: number | null }>>(
`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;
// Highlights only exposes a monthly view, so the cumulative block mirrors
// the monthly values — keeps the CategoryDelta shape uniform for
// downstream consumers that never read the cumulative block.
return {
categoryId: r.category_id,
categoryName: r.category_name,
categoryColor: r.category_color,
previousAmount: previous,
currentAmount: current,
deltaAbs,
deltaPct,
cumulativePreviousAmount: previous,
cumulativeCurrentAmount: current,
cumulativeDeltaAbs: deltaAbs,
cumulativeDeltaPct: deltaPct,
};
});
// 5. Top transactions within the recent window
const recentRows = await db.select<RecentTransaction[]>(
`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;
month_current_total: number | null;
month_previous_total: number | null;
cumulative_current_total: number | null;
cumulative_previous_total: number | null;
}
function rowsToDeltas(rows: RawDeltaRow[]): CategoryDelta[] {
return rows.map((r) => {
const monthCurrent = Number(r.month_current_total ?? 0);
const monthPrevious = Number(r.month_previous_total ?? 0);
const monthDeltaAbs = monthCurrent - monthPrevious;
const monthDeltaPct = monthPrevious === 0 ? null : (monthDeltaAbs / monthPrevious) * 100;
const cumCurrent = Number(r.cumulative_current_total ?? 0);
const cumPrevious = Number(r.cumulative_previous_total ?? 0);
const cumDeltaAbs = cumCurrent - cumPrevious;
const cumDeltaPct = cumPrevious === 0 ? null : (cumDeltaAbs / cumPrevious) * 100;
return {
categoryId: r.category_id,
categoryName: r.category_name,
categoryColor: r.category_color,
// Monthly block (primary — also the legacy field set).
previousAmount: monthPrevious,
currentAmount: monthCurrent,
deltaAbs: monthDeltaAbs,
deltaPct: monthDeltaPct,
// Cumulative YTD block.
cumulativePreviousAmount: cumPrevious,
cumulativeCurrentAmount: cumCurrent,
cumulativeDeltaAbs: cumDeltaAbs,
cumulativeDeltaPct: cumDeltaPct,
};
});
}
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. Returns both a monthly view
* (reference month vs immediately-previous month) and a cumulative YTD view
* (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative
* progress through end of last month" vs "cumulative progress through end of
* this month"). All SQL parameterised.
*/
export async function getCompareMonthOverMonth(
year: number,
month: number,
): Promise<CategoryDelta[]> {
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);
// Cumulative window: from Jan 1st of the reference year up to the end of
// the month we care about. For MoM, the "previous" cumulative window ends
// at the end of the previous month within the SAME year — so Jan only has
// no cumulative-previous (empty window). We still bound the previous-year
// handling: when month === 1 the previous month is in year - 1, so the
// cumulative-previous window sits entirely in year - 1 (Jan → Dec).
const cumCurrentStart = `${year}-01-01`;
const cumCurrentEnd = curEnd;
const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd;
const rows = await db.select<RawDeltaRow[]>(
`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 month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_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)
OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
[
curStart, curEnd,
prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
],
);
return rowsToDeltas(rows);
}
/**
* Year-over-year expense delta by category. Returns both a monthly view
* (reference month vs same month of the previous year) and a cumulative YTD
* view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the
* reference year's December as the "current month" when no explicit
* reference month is provided; callers typically pass the user's chosen
* reference month. All SQL parameterised.
*/
export async function getCompareYearOverYear(
year: number,
month: number = 12,
): Promise<CategoryDelta[]> {
const db = await getDb();
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
const { start: prevMonthStart, end: prevMonthEnd } = monthBoundaries(year - 1, month);
const cumCurrentStart = `${year}-01-01`;
const cumCurrentEnd = curMonthEnd;
const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd;
const rows = await db.select<RawDeltaRow[]>(
`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 month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_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)
OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
[
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
],
);
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<CategoryZoomData> {
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<RecentTransaction[]>(
`${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<Array<{ rollup: number | null }>>(
`${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<Array<{ month: string; total: number | null }>>(
`${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,
};
}
// --- Cartes dashboard (Issue #97) ---
/**
* Signed month shift. Exported for unit tests.
* shiftMonth(2026, 1, -1) -> { year: 2025, month: 12 }
* shiftMonth(2026, 4, -24) -> { year: 2024, month: 4 }
*/
export function shiftMonth(
year: number,
month: number,
offset: number,
): { year: number; month: number } {
const total = year * 12 + (month - 1) + offset;
return {
year: Math.floor(total / 12),
month: (total % 12) + 1,
};
}
function monthKey(year: number, month: number): string {
return `${year}-${String(month).padStart(2, "0")}`;
}
function extractDelta(
current: number | null,
previous: number | null,
): { abs: number | null; pct: number | null } {
if (current === null || previous === null) return { abs: null, pct: null };
const abs = current - previous;
const pct = previous === 0 ? null : (abs / previous) * 100;
return { abs, pct };
}
function buildKpi(
sparkline: CartesSparklinePoint[],
current: number | null,
previousMonth: number | null,
previousYear: number | null,
): CartesKpi {
const mom = extractDelta(current, previousMonth);
const yoy = extractDelta(current, previousYear);
return {
current,
previousMonth,
previousYear,
deltaMoMAbs: mom.abs,
deltaMoMPct: mom.pct,
deltaYoYAbs: yoy.abs,
deltaYoYPct: yoy.pct,
sparkline,
};
}
interface RawMonthFlow {
month: string;
income: number | null;
expenses: number | null;
}
async function fetchMonthlyFlows(
dateFrom: string,
dateTo: string,
): Promise<RawMonthFlow[]> {
const db = await getDb();
return db.select<RawMonthFlow[]>(
`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
WHERE date >= $1 AND date <= $2
GROUP BY month
ORDER BY month ASC`,
[dateFrom, dateTo],
);
}
interface RawSeasonalityRow {
year: number;
amount: number | null;
}
async function fetchSeasonality(
month: number,
yearFrom: number,
yearTo: number,
): Promise<RawSeasonalityRow[]> {
const db = await getDb();
const mm = String(month).padStart(2, "0");
return db.select<RawSeasonalityRow[]>(
`SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS amount
FROM transactions
WHERE strftime('%m', date) = $1
AND CAST(strftime('%Y', date) AS INTEGER) >= $2
AND CAST(strftime('%Y', date) AS INTEGER) <= $3
GROUP BY year
ORDER BY year DESC`,
[mm, yearFrom, yearTo],
);
}
/**
* Cartes dashboard snapshot. Single entry point that returns every widget's
* data for the Cartes report, computed against a reference (year, month).
*
* Layout (all concurrent):
* 1. 25-month expense/income series (covers ref, MoM, YoY, 12-month flow,
* 13-month sparklines without any extra round trips).
* 2. Month-over-month category deltas for top movers (existing service).
* 3. Year-over-year category deltas to seed the savings-rate YoY lookup
* via the monthly series instead of re-querying.
* 4. Budget vs actual for the reference month.
* 5. Seasonality: same calendar month across the two prior years.
*/
export async function getCartesSnapshot(
referenceYear: number,
referenceMonth: number,
): Promise<CartesSnapshot> {
// Date window: 25 months back from the reference to cover YoY + a 13-month
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
const windowStart = shiftMonth(referenceYear, referenceMonth, -24);
const { start: windowStartIso } = monthBoundaries(windowStart.year, windowStart.month);
const { end: refEnd } = monthBoundaries(referenceYear, referenceMonth);
// Seasonality range: previous 2 years for the same calendar month.
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
fetchMonthlyFlows(windowStartIso, refEnd),
getCompareMonthOverMonth(referenceYear, referenceMonth),
getBudgetVsActualData(referenceYear, referenceMonth),
]);
// Index the flow rows by month for O(1) lookup, then fill missing months
// with zeroes so downstream consumers get a contiguous series.
const flowByMonth = new Map<string, { income: number; expenses: number }>();
for (const r of flowRows) {
flowByMonth.set(r.month, {
income: Number(r.income ?? 0),
expenses: Number(r.expenses ?? 0),
});
}
const buildSeries = (count: number): CartesMonthFlow[] => {
const series: CartesMonthFlow[] = [];
for (let i = count - 1; i >= 0; i--) {
const { year: y, month: m } = shiftMonth(referenceYear, referenceMonth, -i);
const key = monthKey(y, m);
const row = flowByMonth.get(key);
const income = row?.income ?? 0;
const expenses = row?.expenses ?? 0;
series.push({ month: key, income, expenses, net: income - expenses });
}
return series;
};
// 13-month sparklines for each KPI (reference month + 12 prior).
const sparkSeries = buildSeries(13);
const incomeSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.income,
}));
const expensesSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.expenses,
}));
const netSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.net,
}));
const savingsSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.income > 0 ? (p.net / p.income) * 100 : 0,
}));
// Compute MoM / YoY values directly from `flowByMonth` (which preserves the
// "missing" distinction). The sparkline fills gaps with zero for display,
// but deltas must remain null when the comparison month has no data.
const refKey = monthKey(referenceYear, referenceMonth);
const momMeta = shiftMonth(referenceYear, referenceMonth, -1);
const momKey = monthKey(momMeta.year, momMeta.month);
const yoyMeta = { year: referenceYear - 1, month: referenceMonth };
const yoyKey = monthKey(yoyMeta.year, yoyMeta.month);
const refRow = flowByMonth.get(refKey);
const refIncome = refRow?.income ?? 0;
const refExpenses = refRow?.expenses ?? 0;
const refNet = refIncome - refExpenses;
// Savings rate is undefined when income is zero — expose as null rather than
// rendering a misleading "0 %" in the UI.
const refSavings = refIncome > 0 ? (refNet / refIncome) * 100 : null;
const momRow = flowByMonth.get(momKey);
const momIncome = momRow ? momRow.income : null;
const momExpenses = momRow ? momRow.expenses : null;
const momNet = momRow ? momRow.income - momRow.expenses : null;
const momSavings =
momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null;
const yoyRow = flowByMonth.get(yoyKey);
const yoyIncome = yoyRow ? yoyRow.income : null;
const yoyExpenses = yoyRow ? yoyRow.expenses : null;
const yoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null;
const yoySavings =
yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null;
const incomeKpi = buildKpi(incomeSpark, refIncome, momIncome, yoyIncome);
const expensesKpi = buildKpi(expensesSpark, refExpenses, momExpenses, yoyExpenses);
const netKpi = buildKpi(netSpark, refNet, momNet, yoyNet);
const savingsKpi = buildKpi(savingsSpark, refSavings, momSavings, yoySavings);
// 12-month income vs expenses series for the overlay chart.
const flow12Months = buildSeries(12);
// Top movers: biggest MoM increases / decreases. `momRows` are sorted by
// absolute delta already; filter out near-zero noise and split by sign.
const significantMovers = momRows.filter(
(r) => r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0),
);
// Project the richer CategoryDelta shape down to the narrower CartesTopMover
// shape so the Cartes dashboard keeps its stable contract regardless of how
// many views (monthly / cumulative) the Compare service exposes.
const toCartesMover = (r: CategoryDelta): CartesTopMover => ({
categoryId: r.categoryId,
categoryName: r.categoryName,
categoryColor: r.categoryColor,
previousAmount: r.previousAmount,
currentAmount: r.currentAmount,
deltaAbs: r.deltaAbs,
deltaPct: r.deltaPct,
});
const topMoversUp: CartesTopMover[] = significantMovers
.filter((r) => r.deltaAbs > 0)
.sort((a, b) => b.deltaAbs - a.deltaAbs)
.slice(0, 5)
.map(toCartesMover);
const topMoversDown: CartesTopMover[] = significantMovers
.filter((r) => r.deltaAbs < 0)
.sort((a, b) => a.deltaAbs - b.deltaAbs)
.slice(0, 5)
.map(toCartesMover);
// Budget adherence — only expense categories with a non-zero budget count.
// monthActual is signed from transactions; expense categories have
// monthActual <= 0, so we compare on absolute values.
const budgetedExpenseRows = budgetRows.filter(
(r) => r.category_type === "expense" && r.monthBudget > 0 && !r.is_parent,
);
const budgetsInTarget = budgetedExpenseRows.filter(
(r) => Math.abs(r.monthActual) <= r.monthBudget,
).length;
const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows
.map((r) => {
const actual = Math.abs(r.monthActual);
const overrunAbs = actual - r.monthBudget;
const overrunPct = r.monthBudget > 0 ? (overrunAbs / r.monthBudget) * 100 : null;
return {
categoryId: r.category_id,
categoryName: r.category_name,
categoryColor: r.category_color,
budget: r.monthBudget,
actual,
overrunAbs,
overrunPct,
};
})
.filter((r) => r.overrunAbs > 0)
.sort((a, b) => b.overrunAbs - a.overrunAbs)
.slice(0, 3);
const budgetAdherence: CartesBudgetAdherence = {
categoriesInTarget: budgetsInTarget,
categoriesTotal: budgetedExpenseRows.length,
worstOverruns: overruns,
};
// Seasonality — average of the same calendar month across the previous
// two years. If no data, average stays null.
const historicalYears: CartesSeasonalityYear[] = seasonalityRows.map((r) => ({
year: Number(r.year),
amount: Number(r.amount ?? 0),
}));
const historicalAverage = historicalYears.length
? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length
: null;
// `refExpenses` is always a concrete number (never null) — unlike
// `savingsKpi.current` which is nullable when income is zero.
const referenceAmount = refExpenses;
const deviationPct =
historicalAverage !== null && historicalAverage > 0
? ((referenceAmount - historicalAverage) / historicalAverage) * 100
: null;
const seasonality: CartesSeasonality = {
referenceAmount,
historicalYears,
historicalAverage,
deviationPct,
};
return {
referenceYear,
referenceMonth,
kpis: {
income: incomeKpi,
expenses: expensesKpi,
net: netKpi,
savingsRate: savingsKpi,
},
flow12Months,
topMoversUp,
topMoversDown,
budgetAdherence,
seasonality,
};
}