Render the real-vs-real comparable report as a parent/child category tree with subtotal rows, mirroring the budget report — rows grouped into expense/income/transfer sections with per-section and grand totals and a subtotals-on-top/bottom toggle. The compare service now builds the tree on top of the flat per-category deltas, so leaf-category figures are unchanged and the #243 transfer netting is preserved (a balanced transfer group subtotals to ~0, i.e. the group's net). - reportService: buildCompareTree() synthesizes subtotal rows from the flat leaves + category metadata; getCompareMonthOverMonth/YoY return the tree (COMPARE_DELTA_SQL and rowsToDeltas untouched). - CategoryDelta gains optional parent_id/is_parent/depth/category_type. - ComparePeriodTable: sections, depth indentation, reorderRows toggle, section/grand net totals. - Cartes top-movers and ComparePeriodChart filter to leaf rows only. - reorderRows constraint loosened to the two fields it reads. - i18n (FR+EN) section labels; CHANGELOG entries. Resolves #247 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1280 lines
46 KiB
TypeScript
1280 lines
46 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,
|
|
CartesKpiPeriodMode,
|
|
} 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 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}` };
|
|
}
|
|
|
|
// --- 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 COMPARE_DELTA_SQL (no regression on the leaves shown).
|
|
*/
|
|
interface CompareCatMeta {
|
|
id: number;
|
|
name: string;
|
|
color: string | null;
|
|
type: "expense" | "income" | "transfer" | null;
|
|
parent_id: number | null;
|
|
}
|
|
|
|
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
|
|
|
|
const COMPARE_TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
|
|
|
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
|
|
const MAX_TREE_DEPTH = 5;
|
|
|
|
/**
|
|
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL 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. Driven by `leaves` (the categories that actually had transactions in the
|
|
* window) rather than the full category list, so netted-to-zero transfers and
|
|
* soft-deleted categories with history are preserved and no empty rows appear.
|
|
*
|
|
* Exported for unit testing.
|
|
*/
|
|
export function buildCompareTree(
|
|
leaves: CategoryDelta[],
|
|
categories: CompareCatMeta[],
|
|
): CategoryDelta[] {
|
|
const catById = new Map<number, CompareCatMeta>();
|
|
for (const c of categories) catById.set(c.id, c);
|
|
|
|
// Flat delta lookup by category id. Rows whose category id is null
|
|
// (Uncategorized) or absent from the table (hard-deleted) become orphans:
|
|
// depth-0 leaves preserved in their incoming order.
|
|
const deltaByCat = new Map<number, CategoryDelta>();
|
|
const orphans: CategoryDelta[] = [];
|
|
for (const d of leaves) {
|
|
if (d.categoryId != null && catById.has(d.categoryId)) {
|
|
deltaByCat.set(d.categoryId, d);
|
|
} else {
|
|
orphans.push(d);
|
|
}
|
|
}
|
|
|
|
// "Relevant" = every category that has a delta plus all of its ancestors.
|
|
const relevant = new Set<number>();
|
|
for (const id of deltaByCat.keys()) {
|
|
let cur: number | null | undefined = id;
|
|
let guard = 0;
|
|
while (cur != null && guard <= MAX_TREE_DEPTH) {
|
|
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, CompareCatMeta[]>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
const typeOf = (c: CompareCatMeta): "expense" | "income" | "transfer" => c.type ?? "expense";
|
|
|
|
const leafRow = (
|
|
cat: CompareCatMeta,
|
|
parentId: number | null,
|
|
depth: number,
|
|
): CategoryDelta => ({
|
|
...deltaByCat.get(cat.id)!,
|
|
parent_id: parentId,
|
|
is_parent: false,
|
|
depth,
|
|
category_type: typeOf(cat),
|
|
});
|
|
|
|
const subtotalRow = (
|
|
cat: CompareCatMeta,
|
|
descendantLeaves: CategoryDelta[],
|
|
parentId: number | null,
|
|
depth: number,
|
|
): CategoryDelta => {
|
|
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),
|
|
};
|
|
};
|
|
|
|
interface Block {
|
|
rows: CategoryDelta[];
|
|
sortKey: number; // |monthly delta| 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 |monthly delta| desc.
|
|
const buildNode = (cat: CompareCatMeta, 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 >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []);
|
|
const hasDirect = deltaByCat.has(cat.id);
|
|
|
|
if (children.length === 0) {
|
|
if (!hasDirect) return null;
|
|
const leaf = leafRow(cat, cat.parent_id ?? null, depth);
|
|
return { rows: [leaf], sortKey: Math.abs(leaf.deltaAbs) };
|
|
}
|
|
|
|
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 (mirrors getBudgetVsActualData). Rare: a parent auto-loses
|
|
// is_inputable when a child is added.
|
|
if (hasDirect) {
|
|
const direct = leafRow(cat, cat.id, depth + 1);
|
|
childBlocks.push({
|
|
rows: [{ ...direct, categoryName: `${cat.name} (direct)` }],
|
|
sortKey: Math.abs(direct.deltaAbs),
|
|
});
|
|
}
|
|
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) => !r.is_parent);
|
|
const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth);
|
|
return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) };
|
|
};
|
|
|
|
// 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 d of orphans) {
|
|
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
|
|
}
|
|
|
|
// Stable sort by type so sections (expense → income → transfer) are
|
|
// contiguous; magnitude/tree order within a type is preserved via the index.
|
|
const order = new Map<CategoryDelta, number>();
|
|
rows.forEach((r, i) => order.set(r, i));
|
|
rows.sort((a, b) => {
|
|
const ta = COMPARE_TYPE_ORDER[a.category_type ?? "expense"] ?? 9;
|
|
const tb = COMPARE_TYPE_ORDER[b.category_type ?? "expense"] ?? 9;
|
|
if (ta !== tb) return ta - tb;
|
|
return order.get(a)! - order.get(b)!;
|
|
});
|
|
|
|
return rows;
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Per-category sign convention (Issue #243):
|
|
* - `transfer` categories NET via a signed SUM(t.amount), so a balanced
|
|
* debit/credit pair (e.g. "Paiement CC": -500 out, +500 in) cancels to ~0.
|
|
* A transfer is a money move, not spending, and must not inflate the
|
|
* expense figures.
|
|
* - Every other type keeps the expense-report behavior: only outflows
|
|
* (amount < 0) count, summed as positive magnitudes via ABS.
|
|
*
|
|
* The WHERE is broadened with `OR COALESCE(c.type, 'expense') = 'transfer'` so
|
|
* transfer *credits* survive the `amount < 0` expense filter and can actually
|
|
* cancel their debits — without it the credit leg would be dropped and the
|
|
* category could never net to zero. For non-transfer rows the WHERE still
|
|
* admits only outflows, so their totals are byte-identical to the previous
|
|
* behavior (no revenues surfaced, no zero-rows for pure-income categories).
|
|
* Uncategorized rows (LEFT JOIN → c.type NULL) default to 'expense'.
|
|
*/
|
|
const COMPARE_DELTA_SQL = `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') = 'transfer' 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') = 'transfer' 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') = 'transfer' 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') = 'transfer' 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') = 'transfer')
|
|
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`;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// 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[]>(COMPARE_DELTA_SQL, [
|
|
curStart, curEnd,
|
|
prevStart, prevEnd,
|
|
cumCurrentStart, cumCurrentEnd,
|
|
cumPreviousStart, cumPreviousEnd,
|
|
]),
|
|
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.
|
|
*/
|
|
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;
|
|
|
|
// See getCompareMonthOverMonth: delta select first, categories alongside.
|
|
const [rows, cats] = await Promise.all([
|
|
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
|
|
curMonthStart, curMonthEnd,
|
|
prevMonthStart, prevMonthEnd,
|
|
cumCurrentStart, cumCurrentEnd,
|
|
cumPreviousStart, cumPreviousEnd,
|
|
]),
|
|
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,
|
|
): 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,
|
|
mode: CartesKpiPeriodMode = "month",
|
|
): 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);
|
|
|
|
// 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. The surviving leaves
|
|
// are byte-identical to the previous flat output; the sort/slice below is
|
|
// unchanged. `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.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,
|
|
};
|
|
}
|