Simpl-Resultat/src/services/reportService.ts
le king fu 9ee5ad353f
All checks were successful
PR Check / rust (pull_request) Successful in 21m29s
PR Check / frontend (pull_request) Successful in 2m24s
fix(reports): thread accountIds into Cartes KPI + seasonality series
getCartesSnapshot forwarded the account filter only to the top-movers and
budget sub-reports, leaving fetchMonthlyFlows (KPIs, sparklines, 12-month
overlay) and fetchSeasonality unfiltered. The Dashboard is the first page to
expose the account filter over these series, so its KPI cards showed
unfiltered totals while the category bars / trend / top-movers respected the
filter — figures that did not reconcile on the same screen.

Thread an optional accountIds through both fetchers (parameterized
`source_id IN (...)` via inPlaceholders) and pass it from getCartesSnapshot,
so the whole dashboard honours the filter — which is what the #279 CHANGELOG
entry already promises. No CHANGELOG change: the code now matches the note.

Resolves #279

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:16:09 -04:00

1551 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService";
import { inPlaceholders } from "../utils/sqlFilters";
import type {
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
OverTimeRow,
HighlightsData,
HighlightMover,
CategoryDelta,
CategoryZoomData,
CategoryZoomChild,
CategoryZoomEvolutionPoint,
MonthBalance,
RecentTransaction,
CartesSnapshot,
CartesKpi,
CartesSparklinePoint,
CartesTopMover,
CartesMonthFlow,
CartesBudgetAdherence,
CartesBudgetWorstOverrun,
CartesSeasonality,
CartesSeasonalityYear,
CartesKpiPeriodMode,
} from "../shared/types";
export async function getMonthlyTrends(
dateFrom?: string,
dateTo?: string,
accountIds?: 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++;
}
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
if (accountPlaceholders) {
whereClauses.push(`source_id IN (${accountPlaceholders})`);
params.push(...accountIds!);
paramIndex += accountIds!.length;
}
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,
accountIds?: 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++;
}
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
if (accountPlaceholders) {
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
params.push(...accountIds!);
paramIndex += accountIds!.length;
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
// Get top N categories by total spend
const topCategories = await db.select<
(CategoryBreakdownItem & { category_type: "expense" | "income" | "transfer" })[]
>(
`SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(c.type, 'expense') AS category_type,
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> = {};
const types: Record<string, "expense" | "income" | "transfer"> = {};
for (const cat of topCategories) {
colors[cat.category_name] = cat.category_color;
categoryIds[cat.category_name] = cat.category_id;
types[cat.category_name] = cat.category_type ?? "expense";
}
// 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");
}
const data = Array.from(monthMap.values());
// --- Id-keyed hierarchical sidecar (Issue #264) ---
// The pivot above stays byte-identical (top-N + "Other", name-keyed) so the
// chart and dashboard are untouched. The tree is a SEPARATE view of the exact
// same `monthlyRows`, keyed by category_id, carrying EVERY category (no top-N,
// no "Other" bucket) so the trends table + its result lines are exact and two
// homonym categories of different types never collide.
const months = data.map((d) => d.month);
const monthIndex = new Map<string, number>();
months.forEach((m, i) => monthIndex.set(m, i));
// Aggregate every category into an id-keyed per-month magnitude series. A null
// category_id (truly uncategorized) collapses to one bucket; a non-null id that
// has no metadata row (hard-deleted) stays distinct and surfaces as an orphan.
const leafByKey = new Map<number | string, OverTimeRow>();
for (const row of monthlyRows) {
const key = row.category_id ?? "__uncategorized__";
let leaf = leafByKey.get(key);
if (!leaf) {
leaf = {
categoryId: row.category_id,
categoryName: row.category_name,
categoryColor: "#9ca3af",
monthly: new Array(months.length).fill(0),
total: 0,
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
leafByKey.set(key, leaf);
}
const idx = monthIndex.get(row.month);
if (idx != null) {
leaf.monthly[idx] += row.total;
leaf.total += row.total;
}
}
const cats = await db.select<TreeCatMeta[]>(
`SELECT id, name, color, type, parent_id FROM categories`,
);
const tree = buildOverTimeTree(Array.from(leafByKey.values()), cats ?? [], months.length);
return {
categories,
data,
colors,
categoryIds,
types,
tree,
};
}
// --- 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 todayIso(today: Date): string {
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
const d = String(today.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/**
* Returns the dashboard "highlights" snapshot for the reports hub:
* - monthly tile — net balance of the REFERENCE month
* - YTD tile — net balance from Jan 1st of `ytdYear` through today
* - 12-month series — last 12 months ending at the reference month
* - top movers — reference month vs the month immediately before it
* - top transactions — biggest absolute amounts in the last `windowDays`
* days ending today
*
* `today` is an optional injection point so unit tests can pin both the YTD
* window and the "recent transactions" window without depending on wall-clock
* time. All SQL is parameterised.
*/
export async function getHighlights(
referenceYear: number,
referenceMonth: number,
ytdYear: number,
windowDays: number = 30,
topMoversLimit: number = 5,
topTransactionsLimit: number = 10,
today: Date = new Date(),
): Promise<HighlightsData> {
const db = await getDb();
const currentMonth = `${referenceYear}-${String(referenceMonth).padStart(2, "0")}`; // YYYY-MM
const { start: currentMonthStart, end: currentMonthEnd } = monthBoundaries(
referenceYear,
referenceMonth,
);
const prev = previousMonth(referenceYear, referenceMonth);
const { start: previousMonthStart, end: previousMonthEnd } = monthBoundaries(
prev.year,
prev.month,
);
// Sparkline anchor = reference month; shift 11 back for a 12-month series.
const sparklineStart = shiftMonthStart(`${currentMonthStart}`, -11);
// YTD window: Jan 1 of `ytdYear` → today. The reference month does not
// move this window — the YTD tile is pinned to the current civil year.
const ytdStart = `${ytdYear}-01-01`;
const todayStr = todayIso(today);
// Guard: if the user picks a reference month in the future (unlikely) or if
// `today` is clamped below Jan 1 (fixtures), cap the YTD end at `ytdStart`
// so SQL never inverts its bounds.
const ytdEnd = todayStr >= ytdStart ? todayStr : ytdStart;
// Recent-transactions window ends today (not at the reference month).
const recentWindowStart = shiftDate(todayStr, -(windowDays - 1));
// 1. Net balance for the reference 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 — independent of the reference month.
const ytdRows = await db.select<Array<{ net: number | null }>>(
`SELECT COALESCE(SUM(amount), 0) AS net
FROM transactions
WHERE date >= $1 AND date <= $2`,
[ytdStart, ytdEnd],
);
const netBalanceYtd = Number(ytdRows[0]?.net ?? 0);
// 3. 12-month sparkline series, ending at the reference month.
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(currentMonthStart, -i).slice(0, 7);
monthlyBalanceSeries.push({ month: monthKey, netBalance: seriesMap.get(monthKey) ?? 0 });
}
// 4. Top movers — expense-side only (amount < 0), compare reference month
// vs the immediately-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 ending today.
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, todayStr, 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}` };
}
// --- Generic leaf-driven tree builder (Issue #263) ---
/** Section key shared by every hierarchical report (income-statement order). */
export type TreeSectionType = "expense" | "income" | "transfer";
/**
* Minimal category metadata every hierarchical report needs: id, display
* name/color, income-statement `type`, and `parent_id` to walk the hierarchy.
* Shared by the compare tree (Issue #247) and the trends tree (Issue #264).
*/
export interface TreeCatMeta {
id: number;
name: string;
color: string | null;
type: TreeSectionType | null;
parent_id: number | null;
}
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5;
/**
* Injection points that specialise the generic tree builder for a concrete row
* type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape
* emitted — leaf/subtotal rows just add the hierarchy fields (`parent_id`,
* `is_parent`, `depth`, …) the caller writes in `makeLeaf` / `makeSubtotal`.
*/
export interface LeafDrivenTreeOptions<T> {
/** Category hierarchy (fetched without an is_active filter — keeps history). */
categories: TreeCatMeta[];
/** Category a leaf belongs to; a null/absent id → orphan (top-level leaf). */
categoryIdOf: (leaf: T) => number | null;
/** Enrich a leaf payload into a leaf row parented at `parentId` / `depth`. */
makeLeaf: (cat: TreeCatMeta, leaf: T, parentId: number | null, depth: number) => T;
/** Reduce a node's descendant leaves into its subtotal (is_parent) row. */
makeSubtotal: (
cat: TreeCatMeta,
descendantLeaves: T[],
parentId: number | null,
depth: number,
) => T;
/** Annotate the "(direct)" leaf of a parent that also has own transactions. */
decorateDirectLeaf: (leafRow: T, cat: TreeCatMeta) => T;
/** Enrich an orphan (Uncategorized / hard-deleted) payload into a root leaf. */
makeOrphan: (leaf: T) => T;
/** Magnitude used to order siblings (descending) — read off a block's head. */
sortKey: (row: T) => number;
/** True when a row is a subtotal, so descendant *leaves* can be summed. */
isSubtotal: (row: T) => boolean;
/** Section key of a row for the final contiguous type sort. */
sectionOf: (row: T) => string;
/** Section order, income-first e.g. `{ income: 0, expense: 1, transfer: 2 }`. */
sectionOrder: Record<string, number>;
/** Rank for a section absent from `sectionOrder` (default 9 — sorts last). */
unknownSectionRank?: number;
/** Cyclic-chain depth cap (default `MAX_TREE_DEPTH`). */
maxDepth?: number;
}
/**
* Generic leaf-driven hierarchy builder. Turns a flat list of per-category
* leaves into a parent/child tree with subtotal rows, ordered by magnitude
* within each type and grouped into contiguous income → expense → transfer
* sections. Driven by the leaves that actually exist (not the full category
* list), so netted-to-zero groups and soft-deleted categories with history are
* preserved and no empty rows appear.
*
* Behavior — including the `MAX_TREE_DEPTH` guard and buildNode loop-break
* against a corrupted parent_id cycle — is shared verbatim by every
* hierarchical report; the row arithmetic is injected via `opts`. Extracted
* from `buildCompareTree` (Issue #263) so the trends tree (Issue #264) reuses
* the exact same skeleton.
*/
export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<T>): T[] {
const {
categories,
categoryIdOf,
makeLeaf,
makeSubtotal,
decorateDirectLeaf,
makeOrphan,
sortKey,
isSubtotal,
sectionOf,
sectionOrder,
unknownSectionRank = 9,
maxDepth = MAX_TREE_DEPTH,
} = opts;
const catById = new Map<number, TreeCatMeta>();
for (const c of categories) catById.set(c.id, c);
// Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or
// absent from the table (hard-deleted) become orphans: depth-0 leaves
// preserved in their incoming order.
const leafByCat = new Map<number, T>();
const orphans: T[] = [];
for (const l of leaves) {
const id = categoryIdOf(l);
if (id != null && catById.has(id)) {
leafByCat.set(id, l);
} else {
orphans.push(l);
}
}
// "Relevant" = every category that has a leaf plus all of its ancestors.
const relevant = new Set<number>();
for (const id of leafByCat.keys()) {
let cur: number | null | undefined = id;
let guard = 0;
while (cur != null && guard <= maxDepth) {
if (relevant.has(cur)) break;
relevant.add(cur);
cur = catById.get(cur)?.parent_id ?? null;
guard++;
}
}
// Adjacency among relevant categories only, preserving DB order.
const childrenByParent = new Map<number, TreeCatMeta[]>();
for (const c of categories) {
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
let arr = childrenByParent.get(c.parent_id);
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
arr.push(c);
}
}
interface Block {
rows: T[];
sortKey: number; // magnitude of the block head — orders siblings
}
// Builds a node's block: a pure leaf, or a subtotal followed by its
// (recursively built) child blocks, siblings ordered by magnitude desc.
const buildNode = (cat: TreeCatMeta, depth: number): Block | null => {
// Stop descending past the depth cap so a corrupted parent_id cycle can
// never recurse forever — deeper nodes collapse to leaves (real category
// trees are ≤ 3 levels).
const children = depth >= maxDepth ? [] : (childrenByParent.get(cat.id) ?? []);
const hasDirect = leafByCat.has(cat.id);
if (children.length === 0) {
if (!hasDirect) return null;
const leaf = makeLeaf(cat, leafByCat.get(cat.id)!, cat.parent_id ?? null, depth);
return { rows: [leaf], sortKey: sortKey(leaf) };
}
const childBlocks: Block[] = [];
// A category with both children AND its own transactions surfaces the direct
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
// rows. Rare: a parent auto-loses is_inputable when a child is added.
if (hasDirect) {
const direct = decorateDirectLeaf(
makeLeaf(cat, leafByCat.get(cat.id)!, cat.id, depth + 1),
cat,
);
childBlocks.push({ rows: [direct], sortKey: sortKey(direct) });
}
for (const child of children) {
const b = buildNode(child, depth + 1);
if (b) childBlocks.push(b);
}
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
const childRows = childBlocks.flatMap((b) => b.rows);
const descendantLeaves = childRows.filter((r) => !isSubtotal(r));
const subtotal = makeSubtotal(cat, descendantLeaves, cat.parent_id ?? null, depth);
return { rows: [subtotal, ...childRows], sortKey: sortKey(subtotal) };
};
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
const rootBlocks: Block[] = [];
for (const c of categories) {
if (!relevant.has(c.id)) continue;
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
const b = buildNode(c, 0);
if (b) rootBlocks.push(b);
}
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
const rows = rootBlocks.flatMap((b) => b.rows);
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
for (const l of orphans) rows.push(makeOrphan(l));
// Stable sort by section so types (income → expense → transfer) are
// contiguous; magnitude/tree order within a type is preserved via the index.
const order = new Map<T, number>();
rows.forEach((r, i) => order.set(r, i));
rows.sort((a, b) => {
const ta = sectionOrder[sectionOf(a)] ?? unknownSectionRank;
const tb = sectionOrder[sectionOf(b)] ?? unknownSectionRank;
if (ta !== tb) return ta - tb;
return order.get(a)! - order.get(b)!;
});
return rows;
}
// --- Compare hierarchy (Issue #247) ---
/**
* Minimal category metadata for building the compare tree. Fetched WITHOUT an
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry
* historic transactions keep their place in the hierarchy — matching the raw
* LEFT JOIN behavior of compareDeltaSql() (no regression on the leaves shown).
*/
type CompareCatMeta = TreeCatMeta;
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
// Income statement reading order: revenues first, then expenses, then the
// (netted) transfers — the two result lines are injected around them in the UI.
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/**
* Turns the flat per-category deltas returned by compareDeltaSql() into a
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
* getBudgetVsActualData.
*
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
* returned, and a subtotal is the arithmetic sum of its descendant leaves — so a
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
* net.
*
* A thin specialisation of `buildLeafDrivenTree` (Issue #263): the generic
* skeleton drives the hierarchy walk, sibling ordering, depth guard and section
* sort; this only supplies the delta arithmetic. Behavior is byte-identical to
* the previous hand-rolled builder — the compare tests are the guard.
*
* Exported for unit testing.
*/
export function buildCompareTree(
leaves: CategoryDelta[],
categories: CompareCatMeta[],
): CategoryDelta[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
return buildLeafDrivenTree<CategoryDelta>(leaves, {
categories,
categoryIdOf: (d) => d.categoryId,
makeLeaf: (cat, d, parentId, depth) => ({
...d,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
let previousAmount = 0;
let currentAmount = 0;
let cumulativePreviousAmount = 0;
let cumulativeCurrentAmount = 0;
for (const l of descendantLeaves) {
previousAmount += l.previousAmount;
currentAmount += l.currentAmount;
cumulativePreviousAmount += l.cumulativePreviousAmount;
cumulativeCurrentAmount += l.cumulativeCurrentAmount;
}
const deltaAbs = currentAmount - previousAmount;
const cumulativeDeltaAbs = cumulativeCurrentAmount - cumulativePreviousAmount;
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
previousAmount,
currentAmount,
deltaAbs,
// Match rowsToDeltas' leaf formula (signed denominator) so a single-child
// parent shows the same % as its child.
deltaPct: previousAmount !== 0 ? (deltaAbs / previousAmount) * 100 : null,
cumulativePreviousAmount,
cumulativeCurrentAmount,
cumulativeDeltaAbs,
cumulativeDeltaPct:
cumulativePreviousAmount !== 0
? (cumulativeDeltaAbs / cumulativePreviousAmount) * 100
: null,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
},
// A parent with both children and its own transactions surfaces the direct
// spend as a "(direct)" leaf (mirrors getBudgetVsActualData).
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
// Orphans keep their own already-computed delta; only the hierarchy fields
// are stamped. Uncategorized rows (category_type undefined) → 'expense'.
makeOrphan: (d) => ({
...d,
parent_id: null,
is_parent: false,
depth: 0,
category_type: d.category_type ?? "expense",
}),
sortKey: (row) => Math.abs(row.deltaAbs),
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
});
}
// --- Trends hierarchy (Issue #264) ---
/**
* Turns the flat per-category monthly leaves of `getCategoryOverTime` into a
* parent/child tree with subtotal (`is_parent`) rows — the id-keyed sidecar to
* the name-keyed pivot. Another thin specialisation of `buildLeafDrivenTree`
* (Issue #263): the generic skeleton drives the hierarchy walk, sibling
* ordering, `MAX_TREE_DEPTH` cycle guard and the income → expense → transfer
* section sort (reusing `COMPARE_TYPE_ORDER`, income-first since #253); this only
* supplies the per-month magnitude arithmetic. A subtotal's `monthly[i]` is the
* sum of its descendant leaves' `monthly[i]`, so section subtotals and the
* result lines can be read straight off the leaves. `monthCount` fixes the
* length of every subtotal's series so it stays index-aligned with the pivot.
*
* Exported for unit testing.
*/
export function buildOverTimeTree(
leaves: OverTimeRow[],
categories: TreeCatMeta[],
monthCount: number,
): OverTimeRow[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
return buildLeafDrivenTree<OverTimeRow>(leaves, {
categories,
categoryIdOf: (l) => l.categoryId,
makeLeaf: (cat, l, parentId, depth) => ({
...l,
categoryColor: cat.color ?? l.categoryColor,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
const monthly = new Array<number>(monthCount).fill(0);
let total = 0;
for (const dl of descendantLeaves) {
for (let i = 0; i < monthCount; i++) monthly[i] += dl.monthly[i] ?? 0;
total += dl.total;
}
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
monthly,
total,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
},
// A parent with both children and its own transactions surfaces the direct
// spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData).
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
// Orphans (Uncategorized / hard-deleted) keep their own series; only the
// hierarchy fields are stamped. No metadata → default 'expense'.
makeOrphan: (l) => ({
...l,
parent_id: null,
is_parent: false,
depth: 0,
category_type: l.category_type ?? "expense",
}),
sortKey: (row) => Math.abs(row.total),
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
});
}
function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
/**
* Shared aggregation for the real-vs-real compare (both MoM and YoY drive the
* exact same query — only the eight date bounds differ). Four date buckets
* ($1..$8): monthly current/previous and cumulative current/previous.
*
* This report is an INCOME STATEMENT ("analyse de résultat", Issue #253), not a
* pure expense report: revenues are surfaced alongside expenses so the compare
* can show a result (revenues − expenses). Per-category sign convention:
* - `income` and `transfer` categories use a signed SUM(t.amount). Income
* credits stay positive; a `transfer` debit/credit pair (e.g. "Paiement CC":
* -500 out, +500 in) nets to ~0 (Issue #243 — a money move, not spending).
* - Every other type keeps the expense-report behavior: only outflows
* (amount < 0) count, summed as positive magnitudes via ABS.
*
* The WHERE admits outflows OR any income/transfer row, so income and transfer
* *credits* survive the `amount < 0` filter — without it a pure-income category
* would be dropped entirely and a transfer could never net to zero. Expense
* rows still contribute only their outflows, byte-identical to before. The two
* result lines (before/after transfers) are computed in ComparePeriodTable from
* the per-type subtotals, not here. Uncategorized rows (c.type NULL) → 'expense'.
*
* `accountIds` (Issue #273) adds an optional `t.source_id IN (...)` clause —
* one bound placeholder per id, starting at $9 (the 8 date bounds occupy
* $1-$8). Omitted/empty → no clause at all, so the query text and results stay
* byte-identical to the pre-#273 behavior.
*/
function compareDeltaSql(accountIds?: number[]): string {
const accountPlaceholders = inPlaceholders(accountIds, 9);
const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
return `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 CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END 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 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))
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)
)${accountFilter}
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`;
}
/**
* 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. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/
export async function getCompareMonthOverMonth(
year: number,
month: number,
accountIds?: 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 deltaParams: unknown[] = [
curStart, curEnd,
prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards
// under-specified mocks — db.select never returns undefined in production.
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
}
/**
* 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. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/
export async function getCompareYearOverYear(
year: number,
month: number = 12,
accountIds?: number[],
): 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 deltaParams: unknown[] = [
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// See getCompareMonthOverMonth: delta select first, categories alongside.
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
}
// --- 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,
accountIds?: number[],
): Promise<RawMonthFlow[]> {
const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, 3);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
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${accountClause}
GROUP BY month
ORDER BY month ASC`,
params,
);
}
interface RawSeasonalityRow {
year: number;
amount: number | null;
}
async function fetchSeasonality(
month: number,
yearFrom: number,
yearTo: number,
accountIds?: number[],
): Promise<RawSeasonalityRow[]> {
const db = await getDb();
const mm = String(month).padStart(2, "0");
const params: unknown[] = [mm, yearFrom, yearTo];
const accountPlaceholders = inPlaceholders(accountIds, 4);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
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${accountClause}
GROUP BY year
ORDER BY year DESC`,
params,
);
}
/**
* 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.
*
* `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
* dashboard honours an active account filter: the top-movers
* (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
* and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
* `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
* filtered and unfiltered figures on the same screen.
*/
export async function getCartesSnapshot(
referenceYear: number,
referenceMonth: number,
mode: CartesKpiPeriodMode = "month",
accountIds?: 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, accountIds),
fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
]);
// 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);
// Monthly aggregates — the "month" mode default behavior.
const refRow = flowByMonth.get(refKey);
const monthRefIncome = refRow?.income ?? 0;
const monthRefExpenses = refRow?.expenses ?? 0;
const monthRefNet = monthRefIncome - monthRefExpenses;
// Savings rate is undefined when income is zero — expose as null rather than
// rendering a misleading "0 %" in the UI.
const monthRefSavings = monthRefIncome > 0 ? (monthRefNet / monthRefIncome) * 100 : null;
const momRow = flowByMonth.get(momKey);
const monthMomIncome = momRow ? momRow.income : null;
const monthMomExpenses = momRow ? momRow.expenses : null;
const monthMomNet = momRow ? momRow.income - momRow.expenses : null;
const monthMomSavings =
momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null;
const yoyRow = flowByMonth.get(yoyKey);
const monthYoyIncome = yoyRow ? yoyRow.income : null;
const monthYoyExpenses = yoyRow ? yoyRow.expenses : null;
const monthYoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null;
const monthYoySavings =
yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null;
// YTD aggregates — sum monthly flows from January of a given year up to (and
// including) `upToMonth`. Reuses the already-fetched `flowByMonth` map, so
// no additional SQL round trip is required. Missing months contribute zero
// to the sum (unlike monthly deltas which preserve the null distinction).
const sumYtd = (year: number, upToMonth: number): { income: number; expenses: number } => {
let income = 0;
let expenses = 0;
for (let m = 1; m <= upToMonth; m++) {
const row = flowByMonth.get(monthKey(year, m));
if (row) {
income += row.income;
expenses += row.expenses;
}
}
return { income, expenses };
};
// Current YTD: Jan→refMonth of refYear.
const ytdCurrent = sumYtd(referenceYear, referenceMonth);
const ytdRefIncome = ytdCurrent.income;
const ytdRefExpenses = ytdCurrent.expenses;
const ytdRefNet = ytdRefIncome - ytdRefExpenses;
const ytdRefSavings = ytdRefIncome > 0 ? (ytdRefNet / ytdRefIncome) * 100 : null;
// YTD MoM: previous YTD window is Jan→(refMonth-1) of refYear. When the
// reference month is January, there is no prior window inside the same
// year, so MoM deltas must be null (not zero).
let ytdMomIncome: number | null = null;
let ytdMomExpenses: number | null = null;
let ytdMomNet: number | null = null;
let ytdMomSavings: number | null = null;
if (referenceMonth > 1) {
const prev = sumYtd(referenceYear, referenceMonth - 1);
ytdMomIncome = prev.income;
ytdMomExpenses = prev.expenses;
ytdMomNet = prev.income - prev.expenses;
ytdMomSavings = prev.income > 0 ? ((prev.income - prev.expenses) / prev.income) * 100 : null;
}
// YTD YoY: same window (Jan→refMonth) of the previous calendar year.
const ytdPrevYear = sumYtd(referenceYear - 1, referenceMonth);
const ytdYoyIncome = ytdPrevYear.income;
const ytdYoyExpenses = ytdPrevYear.expenses;
const ytdYoyNet = ytdPrevYear.income - ytdPrevYear.expenses;
const ytdYoySavings =
ytdPrevYear.income > 0 ? (ytdYoyNet / ytdPrevYear.income) * 100 : null;
// Select the KPI aggregate set based on the requested mode. Sparklines are
// always the 13-month monthly series regardless of mode — the toggle only
// affects the "current" value and its MoM/YoY comparisons.
const isYtd = mode === "ytd";
const refIncome = isYtd ? ytdRefIncome : monthRefIncome;
const refExpenses = isYtd ? ytdRefExpenses : monthRefExpenses;
const refNet = isYtd ? ytdRefNet : monthRefNet;
const refSavings = isYtd ? ytdRefSavings : monthRefSavings;
const kpiMomIncome = isYtd ? ytdMomIncome : monthMomIncome;
const kpiMomExpenses = isYtd ? ytdMomExpenses : monthMomExpenses;
const kpiMomNet = isYtd ? ytdMomNet : monthMomNet;
const kpiMomSavings = isYtd ? ytdMomSavings : monthMomSavings;
const kpiYoyIncome = isYtd ? ytdYoyIncome : monthYoyIncome;
const kpiYoyExpenses = isYtd ? ytdYoyExpenses : monthYoyExpenses;
const kpiYoyNet = isYtd ? ytdYoyNet : monthYoyNet;
const kpiYoySavings = isYtd ? ytdYoySavings : monthYoySavings;
const incomeKpi = buildKpi(incomeSpark, refIncome, kpiMomIncome, kpiYoyIncome);
const expensesKpi = buildKpi(expensesSpark, refExpenses, kpiMomExpenses, kpiYoyExpenses);
const netKpi = buildKpi(netSpark, refNet, kpiMomNet, kpiYoyNet);
const savingsKpi = buildKpi(savingsSpark, refSavings, kpiMomSavings, kpiYoySavings);
// 12-month income vs expenses series for the overlay chart.
const flow12Months = buildSeries(12);
// Top movers: biggest MoM increases / decreases. `momRows` now carries the
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. Keep only EXPENSE
// leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
// net transfers), momRows now also carries revenue rows — and this card is a
// spending view whose colours read "up = red" (more spending is bad), so a
// salary rise must not appear under "biggest increases" in red. Mirror the
// expense-only filter ComparePeriodChart uses. The surviving expense leaves
// are byte-identical to the pre-#253 flat output. `momRows` are sorted by
// absolute delta already; filter out near-zero noise and split by sign.
const significantMovers = momRows.filter(
(r) =>
!r.is_parent &&
(r.category_type ?? "expense") === "expense" &&
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.
// Both `monthActual` and `monthBudget` are signed (expense categories are
// stored with a negative sign by `budgetService`), so all comparisons and
// deltas on the adherence card must go through `Math.abs` — otherwise the
// `> 0` filter rejects every expense row and the card shows "no budgeted
// categories this month" even when budgets exist.
const budgetedExpenseRows = budgetRows.filter(
(r) => r.category_type === "expense" && Math.abs(r.monthBudget) > 0 && !r.is_parent,
);
const budgetsInTarget = budgetedExpenseRows.filter(
(r) => Math.abs(r.monthActual) <= Math.abs(r.monthBudget),
).length;
const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows
.map((r) => {
const actual = Math.abs(r.monthActual);
const budget = Math.abs(r.monthBudget);
const overrunAbs = actual - budget;
const overrunPct = budget > 0 ? (overrunAbs / budget) * 100 : null;
return {
categoryId: r.category_id,
categoryName: r.category_name,
categoryColor: r.category_color,
budget,
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;
// Seasonality compares the reference month's expenses to the same calendar
// month in previous years — it is intentionally insensitive to the KPI
// period toggle, so always use the monthly expenses (not the YTD figure).
const referenceAmount = monthRefExpenses;
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,
};
}