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>
1551 lines
58 KiB
TypeScript
1551 lines
58 KiB
TypeScript
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,
|
||
};
|
||
}
|