feat(reports): hierarchical real-vs-real compare with subtotals

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>
This commit is contained in:
le king fu 2026-07-04 17:32:38 -04:00
parent 4b000d022f
commit f3e8e94b16
10 changed files with 680 additions and 103 deletions

View file

@ -2,6 +2,10 @@
## [Non publié] ## [Non publié]
### Ajouté
- Rapports → Comparaison (réel vs réel) : le rapport comparable s'affiche désormais en **hiérarchie de catégories avec sous-totaux**, comme le rapport de budget. Les catégories parentes regroupent leurs enfants dans une ligne de sous-total — sa valeur est le net du groupe, donc un groupe de transferts équilibrés s'annule à ~0 — avec les lignes réparties en sections Dépenses / Revenus / Transferts portant des totaux par section et un total général, plus un choix sous-totaux en haut/en bas. Les montants des catégories feuilles sont inchangés, et l'annulation des transferts du correctif précédent est préservée (#247).
### Sécurité ### Sécurité
- Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238). - Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238).

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Added
- Reports → Compare (real vs real): the comparable report is now shown as a **category hierarchy with subtotals**, like the budget report. Parent categories roll their children up into a subtotal row — its value is the group's net, so a group of balanced transfers subtotals to ~0 — with rows grouped into Expenses / Income / Transfers sections carrying per-section and grand totals, and a subtotals-on-top/bottom toggle. Leaf-category figures are unchanged, and the transfer netting from the previous fix is preserved (#247).
### Security ### Security
- Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238). - Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238).

View file

@ -42,8 +42,11 @@ export default function ComparePeriodChart({
// Sort by current-period amount (largest spending first) so the user's eye // Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at // lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart. // the top of the vertical bar chart. The table view groups by parent/child
const chartData = [...rows] // (Issue #247); the chart stays flat, so drop subtotal (is_parent) rows to
// avoid double-counting a group against its own leaves.
const chartData = rows
.filter((r) => !r.is_parent)
.sort((a, b) => b.currentAmount - a.currentAmount) .sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({ .map((r) => ({
name: r.categoryName, name: r.categoryName,

View file

@ -1,5 +1,8 @@
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
export interface ComparePeriodTableProps { export interface ComparePeriodTableProps {
rows: CategoryDelta[]; rows: CategoryDelta[];
@ -48,22 +51,24 @@ function variationColor(value: number): string {
return ""; return "";
} }
export default function ComparePeriodTable({ const STORAGE_KEY = "compare-subtotals-position";
rows,
previousLabel,
currentLabel,
cumulativePreviousLabel,
cumulativeCurrentLabel,
}: ComparePeriodTableProps) {
const { t, i18n } = useTranslation();
const monthPrevLabel = previousLabel; type SectionType = "expense" | "income" | "transfer";
const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Totals across all rows (there is no parent/child structure in compare mode). /** Aggregate of the 6 comparable figures across a set of leaf rows. */
const totals = rows.reduce( interface Totals {
monthCurrent: number;
monthPrevious: number;
monthDelta: number;
ytdCurrent: number;
ytdPrevious: number;
ytdDelta: number;
}
function sumLeaves(rows: CategoryDelta[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(acc, r) => ({ (acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount, monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount, monthPrevious: acc.monthPrevious + r.previousAmount,
@ -74,13 +79,75 @@ export default function ComparePeriodTable({
}), }),
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 }, { monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
); );
const totalMonthPct = }
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null;
const totalYtdPct = function pct(delta: number, previous: number): number | null {
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null; return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
}
export default function ComparePeriodTable({
rows,
previousLabel,
currentLabel,
cumulativePreviousLabel,
cumulativeCurrentLabel,
}: ComparePeriodTableProps) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "top";
});
const toggleSubtotals = () => {
setSubtotalsOnTop((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
return next;
});
};
const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Group rows into contiguous type sections (the service already type-sorts).
const sectionLabels: Record<SectionType, string> = {
expense: t("reports.compare.sections.expenses"),
income: t("reports.compare.sections.income"),
transfer: t("reports.compare.sections.transfers"),
};
const sectionTotalKeys: Record<SectionType, string> = {
expense: "reports.compare.totalExpenses",
income: "reports.compare.totalIncome",
transfer: "reports.compare.totalTransfers",
};
const sections: { type: SectionType; rows: CategoryDelta[] }[] = [];
let currentType: SectionType | null = null;
for (const row of rows) {
const type = (row.category_type ?? "expense") as SectionType;
if (type !== currentType) {
currentType = type;
sections.push({ type, rows: [] });
}
sections[sections.length - 1].rows.push(row);
}
// Grand totals across every leaf.
const totals = sumLeaves(rows);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}> <div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
@ -138,21 +205,51 @@ export default function ComparePeriodTable({
<tbody> <tbody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td <td colSpan={9} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
colSpan={9}
className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"
>
{t("reports.empty.noData")} {t("reports.empty.noData")}
</td> </td>
</tr> </tr>
) : ( ) : (
<> <>
{rows.map((row) => ( {sections.map((section) => {
<tr const sectionTotals = sumLeaves(section.rows);
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`} return (
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40" <Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td
colSpan={9}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{sectionLabels[section.type]}
</td>
</tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent ?? false;
const depth = row.depth ?? 0;
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<td
className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}
> >
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -163,84 +260,130 @@ export default function ComparePeriodTable({
</td> </td>
{/* Monthly block */} {/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)} {formatCurrency(row.currentAmount, lang)}
</td> </td>
<td className="text-right px-3 py-1.5 tabular-nums"> <td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)} {formatCurrency(row.previousAmount, lang)}
</td> </td>
<td <td
className="text-right px-3 py-1.5 tabular-nums font-medium" className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }} style={{ color: variationColor(row.deltaAbs) }}
> >
{formatSignedCurrency(row.deltaAbs, i18n.language)} {formatSignedCurrency(row.deltaAbs, lang)}
</td> </td>
<td <td
className="text-right px-3 py-1.5 tabular-nums" className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }} style={{ color: variationColor(row.deltaAbs) }}
> >
{formatPct(row.deltaPct, i18n.language)} {formatPct(row.deltaPct, lang)}
</td> </td>
{/* Cumulative YTD block */} {/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)} {formatCurrency(row.cumulativeCurrentAmount, lang)}
</td> </td>
<td className="text-right px-3 py-1.5 tabular-nums"> <td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, i18n.language)} {formatCurrency(row.cumulativePreviousAmount, lang)}
</td> </td>
<td <td
className="text-right px-3 py-1.5 tabular-nums font-medium" className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }} style={{ color: variationColor(row.cumulativeDeltaAbs) }}
> >
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)} {formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
</td> </td>
<td <td
className="text-right px-3 py-1.5 tabular-nums" className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }} style={{ color: variationColor(row.cumulativeDeltaAbs) }}
> >
{formatPct(row.cumulativeDeltaPct, i18n.language)} {formatPct(row.cumulativeDeltaPct, lang)}
</td> </td>
</tr> </tr>
))} );
})}
{/* Section net total */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(sectionTotalKeys[section.type])}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
</td>
</tr>
</Fragment>
);
})}
{/* Grand totals row */} {/* Grand totals row */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"> <tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10"> <td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.totalRow")} {t("reports.compare.totalRow")}
</td> </td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, i18n.language)} {formatCurrency(totals.monthCurrent, lang)}
</td> </td>
<td className="text-right px-3 py-3 tabular-nums"> <td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, i18n.language)} {formatCurrency(totals.monthPrevious, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }} style={{ color: variationColor(totals.monthDelta) }}
> >
{formatSignedCurrency(totals.monthDelta, i18n.language)} {formatSignedCurrency(totals.monthDelta, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }} style={{ color: variationColor(totals.monthDelta) }}
> >
{formatPct(totalMonthPct, i18n.language)} {formatPct(pct(totals.monthDelta, totals.monthPrevious), lang)}
</td> </td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, i18n.language)} {formatCurrency(totals.ytdCurrent, lang)}
</td> </td>
<td className="text-right px-3 py-3 tabular-nums"> <td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, i18n.language)} {formatCurrency(totals.ytdPrevious, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }} style={{ color: variationColor(totals.ytdDelta) }}
> >
{formatSignedCurrency(totals.ytdDelta, i18n.language)} {formatSignedCurrency(totals.ytdDelta, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }} style={{ color: variationColor(totals.ytdDelta) }}
> >
{formatPct(totalYtdPct, i18n.language)} {formatPct(pct(totals.ytdDelta, totals.ytdPrevious), lang)}
</td> </td>
</tr> </tr>
</> </>

View file

@ -427,7 +427,15 @@
"referenceMonth": "Reference month", "referenceMonth": "Reference month",
"currentAmount": "Current", "currentAmount": "Current",
"previousAmount": "Previous", "previousAmount": "Previous",
"totalRow": "Total" "totalRow": "Total",
"sections": {
"expenses": "Expenses",
"income": "Income",
"transfers": "Transfers"
},
"totalExpenses": "Total Expenses",
"totalIncome": "Total Income",
"totalTransfers": "Total Transfers"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Key indicators for the reference month", "kpiSectionAria": "Key indicators for the reference month",

View file

@ -427,7 +427,15 @@
"referenceMonth": "Mois de référence", "referenceMonth": "Mois de référence",
"currentAmount": "Courant", "currentAmount": "Courant",
"previousAmount": "Précédent", "previousAmount": "Précédent",
"totalRow": "Total" "totalRow": "Total",
"sections": {
"expenses": "Dépenses",
"income": "Revenus",
"transfers": "Transferts"
},
"totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence", "kpiSectionAria": "Indicateurs clés du mois de référence",

View file

@ -5,7 +5,9 @@ import {
getCompareMonthOverMonth, getCompareMonthOverMonth,
getCompareYearOverYear, getCompareYearOverYear,
getCategoryZoom, getCategoryZoom,
buildCompareTree,
} from "./reportService"; } from "./reportService";
import type { CategoryDelta } from "../shared/types";
// Mock the db module // Mock the db module
vi.mock("./db", () => { vi.mock("./db", () => {
@ -325,7 +327,10 @@ describe("getCompareMonthOverMonth", () => {
await getCompareMonthOverMonth(2026, 4); await getCompareMonthOverMonth(2026, 4);
expect(mockSelect).toHaveBeenCalledTimes(1); // Two queries now: the delta aggregation (call 0, unchanged) + the category
// metadata for the hierarchy (call 1, Issue #247).
expect(mockSelect).toHaveBeenCalledTimes(2);
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
const sql = mockSelect.mock.calls[0][0] as string; const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[]; const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("$1"); expect(sql).toContain("$1");
@ -641,3 +646,167 @@ describe("getCategoryZoom", () => {
expect(txSql).toContain("t.category_id = $1"); expect(txSql).toContain("t.category_id = $1");
}); });
}); });
describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
// Flat leaf, mirroring what rowsToDeltas returns from COMPARE_DELTA_SQL.
function leaf(
categoryId: number | null,
categoryName: string,
mc: number,
mp: number,
cc = mc,
cp = mp,
): CategoryDelta {
return {
categoryId,
categoryName,
categoryColor: "#000",
previousAmount: mp,
currentAmount: mc,
deltaAbs: mc - mp,
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
cumulativePreviousAmount: cp,
cumulativeCurrentAmount: cc,
cumulativeDeltaAbs: cc - cp,
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
};
}
const cats = [
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
] as Parameters<typeof buildCompareTree>[1];
it("nests leaves under a parent subtotal equal to the sum of its children", () => {
const rows = buildCompareTree(
[leaf(22, "Épicerie", 500, 400, 2000, 1500), leaf(24, "Restaurant", 120, 200, 300, 500)],
cats,
);
// Parent subtotal on top, then children ordered by |monthly delta| desc.
expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);
const parent = rows[0];
expect(parent.is_parent).toBe(true);
expect(parent.categoryId).toBe(2);
expect(parent.depth).toBe(0);
expect(parent.category_type).toBe("expense");
// Subtotal = sum of children (the group's net).
expect(parent.currentAmount).toBe(620);
expect(parent.previousAmount).toBe(600);
expect(parent.deltaAbs).toBe(20);
expect(parent.cumulativeCurrentAmount).toBe(2300);
expect(parent.cumulativePreviousAmount).toBe(2000);
expect(parent.cumulativeDeltaAbs).toBe(300);
// Children are leaves at depth 1 with UNCHANGED values (no regression).
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
expect(epicerie.is_parent).toBeFalsy();
expect(epicerie.depth).toBe(1);
expect(epicerie.parent_id).toBe(2);
expect(epicerie.currentAmount).toBe(500);
expect(epicerie.previousAmount).toBe(400);
expect(epicerie.deltaAbs).toBe(100);
});
it("keeps a balanced transfer group netted to 0 at the subtotal (preserves #243)", () => {
const rows = buildCompareTree([leaf(50, "REER", 0, 0, 0, 0)], cats);
const parent = rows.find((r) => r.categoryId === 5)!;
expect(parent.is_parent).toBe(true);
expect(parent.category_type).toBe("transfer");
// The netted-to-zero transfer stays 0 through the subtotal — not re-inflated.
expect(parent.currentAmount).toBe(0);
expect(parent.previousAmount).toBe(0);
expect(parent.deltaAbs).toBe(0);
});
it("orders sections expense → income → transfer and keeps subtrees contiguous", () => {
const rows = buildCompareTree(
[leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
cats,
);
const types = rows.map((r) => r.category_type);
// All expense rows precede all transfer rows.
expect(types).toEqual(["expense", "expense", "transfer", "transfer"]);
});
it("preserves grand-total invariance vs the flat leaves", () => {
const flat = [
leaf(22, "Épicerie", 500, 400, 2000, 1500),
leaf(24, "Restaurant", 120, 200, 300, 500),
leaf(50, "REER", 0, 0, 0, 0),
];
const rows = buildCompareTree(flat, cats);
const sumLeaves = (rs: CategoryDelta[]) =>
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.currentAmount, 0);
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
});
it("surfaces an uncategorized (null id) row as a top-level leaf", () => {
const rows = buildCompareTree([leaf(null, "Uncategorized", 90, 40)], cats);
expect(rows).toHaveLength(1);
expect(rows[0].categoryName).toBe("Uncategorized");
expect(rows[0].is_parent).toBeFalsy();
expect(rows[0].depth).toBe(0);
expect(rows[0].currentAmount).toBe(90);
});
it("keeps a leaf whose category was soft-deleted (still in the metadata)", () => {
// A single leaf with no siblings collapses to just that leaf (no lone subtotal).
const rows = buildCompareTree([leaf(22, "Épicerie", 75, 60)], cats);
const names = rows.map((r) => r.categoryName);
expect(names).toContain("Épicerie");
// A parent with exactly one contributing child still gets a subtotal row.
const parent = rows.find((r) => r.is_parent);
expect(parent?.currentAmount).toBe(75);
});
});
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
it("returns parent subtotal rows when category metadata is available", async () => {
mockSelect
// 1. COMPARE_DELTA_SQL — two sibling leaves under parent id 2.
.mockResolvedValueOnce([
{
category_id: 22,
category_name: "Épicerie",
category_color: "#10b981",
month_current_total: 500,
month_previous_total: 400,
cumulative_current_total: 2000,
cumulative_previous_total: 1500,
},
{
category_id: 24,
category_name: "Restaurant",
category_color: "#f97316",
month_current_total: 120,
month_previous_total: 200,
cumulative_current_total: 300,
cumulative_previous_total: 500,
},
])
// 2. COMPARE_CATEGORIES_SQL — the hierarchy.
.mockResolvedValueOnce([
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
]);
const result = await getCompareMonthOverMonth(2026, 4);
// Delta query stays call 0 (netting SQL + params assertions elsewhere rely on it).
expect(mockSelect.mock.calls[0][0]).toContain("month_current_total");
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
const parent = result.find((r) => r.is_parent);
expect(parent).toBeDefined();
expect(parent!.categoryId).toBe(2);
expect(parent!.currentAmount).toBe(620);
// Leaves survive unchanged.
expect(result.find((r) => r.categoryName === "Épicerie")!.currentAmount).toBe(500);
});
});

View file

@ -429,6 +429,216 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; 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 } { function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 }; if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 }; return { year, month: month - 1 };
@ -502,13 +712,19 @@ export async function getCompareMonthOverMonth(
const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd; const cumPreviousEnd = prevEnd;
const rows = await db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [ // 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, curStart, curEnd,
prevStart, prevEnd, prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
]),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return rowsToDeltas(rows); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
} }
/** /**
@ -532,13 +748,17 @@ export async function getCompareYearOverYear(
const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd; const cumPreviousEnd = prevMonthEnd;
const rows = await db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [ // See getCompareMonthOverMonth: delta select first, categories alongside.
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
curMonthStart, curMonthEnd, curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd, prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
]),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return rowsToDeltas(rows); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
} }
// --- Category zoom (Issue #74) --- // --- Category zoom (Issue #74) ---
@ -946,10 +1166,14 @@ export async function getCartesSnapshot(
// 12-month income vs expenses series for the overlay chart. // 12-month income vs expenses series for the overlay chart.
const flow12Months = buildSeries(12); const flow12Months = buildSeries(12);
// Top movers: biggest MoM increases / decreases. `momRows` are sorted by // Top movers: biggest MoM increases / decreases. `momRows` now carries the
// absolute delta already; filter out near-zero noise and split by sign. // 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( const significantMovers = momRows.filter(
(r) => r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0), (r) => !r.is_parent && r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0),
); );
// Project the richer CategoryDelta shape down to the narrower CartesTopMover // Project the richer CategoryDelta shape down to the narrower CartesTopMover
// shape so the Cartes dashboard keeps its stable contract regardless of how // shape so the Cartes dashboard keeps its stable contract regardless of how

View file

@ -308,6 +308,16 @@ export interface CategoryDelta {
cumulativeCurrentAmount: number; cumulativeCurrentAmount: number;
cumulativeDeltaAbs: number; cumulativeDeltaAbs: number;
cumulativeDeltaPct: number | null; cumulativeDeltaPct: number | null;
// Hierarchy block (Issue #247) — populated ONLY by the real-vs-real Compare
// tree builder (getCompareMonthOverMonth / getCompareYearOverYear). Flat
// consumers (Highlights movers, Cartes top movers) leave these undefined and
// must not read them. Snake_case (unlike the camelCase value fields above) to
// mirror BudgetVsActualRow and stay compatible with the shared `reorderRows`
// util. `is_parent` rows are subtotals whose value = the net of their group.
parent_id?: number | null;
is_parent?: boolean;
depth?: number;
category_type?: "expense" | "income" | "transfer";
} }
// Historical alias — used by the highlights hub. Shape identical to CategoryDelta. // Historical alias — used by the highlights hub. Shape identical to CategoryDelta.

View file

@ -2,9 +2,13 @@
* Shared utility for reordering budget table rows. * Shared utility for reordering budget table rows.
* Recursively moves subtotal (parent) rows below their children * Recursively moves subtotal (parent) rows below their children
* at every depth level when "subtotals on bottom" is enabled. * at every depth level when "subtotals on bottom" is enabled.
*
* The generic constraint only requires the two fields the algorithm actually
* reads (`is_parent`, `depth`) so it works for both the budget rows (required
* `is_parent`) and the Compare `CategoryDelta` rows (optional `is_parent`).
*/ */
export function reorderRows< export function reorderRows<
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }, T extends { is_parent?: boolean; depth?: number },
>(rows: T[], subtotalsOnTop: boolean): T[] { >(rows: T[], subtotalsOnTop: boolean): T[] {
if (subtotalsOnTop) return rows; if (subtotalsOnTop) return rows;