Compare commits

..

No commits in common. "fe87313ba367f0e714891e3b8ac20da0b47f3d29" and "5ade8650cf4b64849e3ae50da5fa2287ff479bc2" have entirely different histories.

14 changed files with 335 additions and 1150 deletions

View file

@ -5,15 +5,6 @@
### Ajouté ### Ajouté
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254). - Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
### Corrigé
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat couvre désormais **toutes** les catégories au lieu des 50 plus grosses, et ses lignes de Résultat sont exactes. Deux catégories différentes portant le même nom ne sont plus fusionnées, et un petit revenu ou transfert n'est plus comptabilisé comme une dépense — les revenus et le résultat net s'additionnent donc correctement (#264).
### Modifié
- Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05

View file

@ -5,15 +5,6 @@
### Added ### Added
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254). - Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
### Fixed
- Reports → Trends → by category (table view): the income-statement table now covers **every** category instead of only the 50 largest, and its Result lines are exact. Two different categories that happen to share a name are no longer merged together, and a smaller income or transfer category is no longer miscounted as an expense — so the revenues and the net result add up correctly (#264).
### Changed
- Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05

View file

@ -59,9 +59,6 @@ export default function CategoryOverTimeChart({
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null); const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
// Reads the name-keyed pivot (`data.categories`), which stays top-N-capped
// (Issue #264 sidecar) so the stacked series never explode to 80+ layers. The
// full, uncapped hierarchy lives in `data.tree` and drives the table instead.
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name)); const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
const categoryEntries = visibleCategories.map((name, index) => ({ const categoryEntries = visibleCategories.map((name, index) => ({
name, name,

View file

@ -1,15 +1,7 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import type { CategoryOverTimeData } from "../../shared/types";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; import { computeOverTimeResults, type OverTimeType } from "./overTimeResults";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
type OverTimeRenderSection,
} from "./overTimeTableModel";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
@ -41,19 +33,12 @@ const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
interface CategoryOverTimeTableProps { interface CategoryOverTimeTableProps {
data: CategoryOverTimeData; data: CategoryOverTimeData;
hiddenCategories?: Set<string>;
} }
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) { export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
// persisted under a key distinct from the comparable tables. Called before the
// early return so the hook order stays stable.
const groups = useCollapsibleGroups<OverTimeRow>(
OVERTIME_EXPANDED_STORAGE_KEY,
OVERTIME_COLLAPSE_ACCESSORS,
);
if (data.data.length === 0) { if (data.data.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -62,21 +47,34 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
); );
} }
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and const visibleCategories = hiddenCategories?.size
// the result lines are exact, and homonym categories never collide. ? data.categories.filter((name) => !hiddenCategories.has(name))
const analysis = computeOverTimeResults(data); : data.categories;
const { months, hasTransfers, net, beforeTransfers } = analysis;
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree); const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories);
const colSpan = months.length + 2; const colSpan = months.length + 2;
const hasGroups = groups.groupCount(data.tree) > 0; return (
const allExpanded = groups.allExpanded(data.tree); <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
// A section = its header, its (visibly) indented hierarchy rows, and a <table className="w-full text-sm whitespace-nowrap">
// leaf-summed subtotal row. Only top-level parents collapse (parity with the <thead className="sticky top-0 z-20">
// comparable tables); the subtotal is always the reducer's leaf sum, so it <tr className="border-b border-[var(--border)] bg-[var(--card)]">
// never changes when a group is folded away. <th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
const renderSection = (section: OverTimeRenderSection) => ( {t("budget.category")}
</th>
{months.map((month) => (
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
{formatMonth(month)}
</th>
))}
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
{t("common.total")}
</th>
</tr>
</thead>
<tbody>
{sections.map((section) => (
<Fragment key={section.type}> <Fragment key={section.type}>
{/* Section header */} {/* Section header */}
<tr className="bg-[var(--muted)]"> <tr className="bg-[var(--muted)]">
@ -87,65 +85,26 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
{t(SECTION_LABEL_KEY[section.type])} {t(SECTION_LABEL_KEY[section.type])}
</td> </td>
</tr> </tr>
{/* Category rows — neutral leaf/parent magnitudes (not deltas) */} {/* Category rows — neutral levels, not deltas */}
{groups.visible(section.rows).map((row) => { {section.categories.map((category) => {
const isParent = row.is_parent; const rowTotal = data.data.reduce(
const depth = row.depth; (sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0),
const isTopParent = isParent && depth === 0; 0,
const isIntermediateParent = isParent && depth >= 1; );
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr key={category} className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40">
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`} <td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
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)]`
}`}
>
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<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"
style={{ backgroundColor: row.categoryColor }} style={{ backgroundColor: data.colors[category] }}
/> />
{row.categoryName} {category}
</span> </span>
)}
</td> </td>
{months.map((month, monthIdx) => { {months.map((month) => {
const value = row.monthly[monthIdx] ?? 0; const monthData = data.data.find((d) => d.month === month);
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
return ( return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums"> <td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"} {value ? cadFormatter(value) : "—"}
@ -153,7 +112,7 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
); );
})} })}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(row.total)} {cadFormatter(rowTotal)}
</td> </td>
</tr> </tr>
); );
@ -173,78 +132,56 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
</td> </td>
</tr> </tr>
</Fragment> </Fragment>
); ))}
// A result line (before-transfers subtotal or the net bottom line). Amounts are {/* Result before transfers — shown only when transfers exist */}
// coloured by sign (surplus green / deficit red). Computed from the raw tree, {hasTransfers && (
// so folding groups never moves these figures. <tr className="border-t-2 border-[var(--border)] font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
<tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
strong ? "border-t-2 border-[var(--border)]" : "border-b border-[var(--border)]"
}`}
>
<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(labelKey)} {t("reports.compare.resultBeforeTransfers")}
</td> </td>
{months.map((month) => ( {months.map((month) => (
<td <td
key={month} key={month}
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(series.monthly[month]) }} style={{ color: resultColor(beforeTransfers.monthly[month]) }}
> >
{cadFormatter(series.monthly[month])} {cadFormatter(beforeTransfers.monthly[month])}
</td> </td>
))} ))}
<td <td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums" className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(series.total) }} style={{ color: resultColor(beforeTransfers.total) }}
> >
{cadFormatter(series.total)} {cadFormatter(beforeTransfers.total)}
</td> </td>
</tr> </tr>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{hasGroups && (
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))}
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"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
</div>
)} )}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}> {/* Net result — bottom line (income expenses + transfers) */}
<table className="w-full text-sm whitespace-nowrap"> <tr
<thead className="sticky top-0 z-20"> className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> hasTransfers ? "border-b border-[var(--border)]" : "border-t-2 border-[var(--border)]"
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]"> }`}
{t("budget.category")} >
</th> <td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.resultNet")}
</td>
{months.map((month) => ( {months.map((month) => (
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]"> <td
{formatMonth(month)} key={month}
</th> className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(net.monthly[month]) }}
>
{cadFormatter(net.monthly[month])}
</td>
))} ))}
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]"> <td
{t("common.total")} className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
</th> style={{ color: resultColor(net.total) }}
>
{cadFormatter(net.total)}
</td>
</tr> </tr>
</thead>
<tbody>
{nonTransferSections.map(renderSection)}
{/* Operating result (revenues expenses), interleaved before the
transfers section only when transfers exist otherwise it equals
the net total below and would just be noise. */}
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
{transferSection && renderSection(transferSection)}
{/* Bottom line: result after netting transfers. */}
{renderResultRow("reports.compare.resultNet", net, true)}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -1,41 +1,12 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults"; import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; import type { CategoryOverTimeData } from "../../shared/types";
/** Builds an id-keyed leaf row; `monthly` is index-aligned to the months list. */ function makeData(
function leaf( data: CategoryOverTimeData["data"],
categoryId: number | null, types: Record<string, "income" | "expense" | "transfer">,
categoryName: string, ): CategoryOverTimeData {
monthly: number[], return { categories: Object.keys(types), data, colors: {}, categoryIds: {}, types };
category_type: "income" | "expense" | "transfer",
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
/** Wraps a month list + id-keyed tree into a CategoryOverTimeData. The pivot
* fields are irrelevant here computeOverTimeResults reads `data.tree`, using
* `data.data` only for the (ordered) month list. */
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
} }
describe("OVER_TIME_TYPE_ORDER", () => { describe("OVER_TIME_TYPE_ORDER", () => {
@ -45,21 +16,19 @@ describe("OVER_TIME_TYPE_ORDER", () => {
}); });
describe("computeOverTimeResults", () => { describe("computeOverTimeResults", () => {
it("groups leaves into ordered sections with per-month + total subtotals", () => { it("groups categories into ordered sections with per-month + total subtotals", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"],
[ [
leaf(2, "Rent", [1000, 1000], "expense"), { month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
leaf(4, "Savings", [200, 200], "transfer"), { month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
leaf(1, "Salary", [3000, 3000], "income"),
leaf(3, "Groceries", [500, 700], "expense"),
], ],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
expect(r.months).toEqual(["2025-01", "2025-02"]); expect(r.months).toEqual(["2025-01", "2025-02"]);
// Income-first ordering, regardless of the input row order. // Income-first ordering, regardless of the input category order.
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]); expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
const income = r.sections.find((s) => s.type === "income")!; const income = r.sections.find((s) => s.type === "income")!;
@ -68,7 +37,7 @@ describe("computeOverTimeResults", () => {
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 }); expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
expect(income.total).toBe(6000); expect(income.total).toBe(6000);
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Rent", "Groceries"]); expect(expense.categories).toEqual(["Rent", "Groceries"]);
expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 }); expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
expect(expense.total).toBe(3200); expect(expense.total).toBe(3200);
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 }); expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
@ -77,16 +46,14 @@ describe("computeOverTimeResults", () => {
it("computes net (income expense + transfer) and before-transfers per month", () => { it("computes net (income expense + transfer) and before-transfers per month", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"],
[ [
leaf(1, "Salary", [3000, 3000], "income"), { month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
leaf(2, "Rent", [1000, 1000], "expense"), { month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
leaf(3, "Groceries", [500, 700], "expense"),
leaf(4, "Savings", [200, 200], "transfer"),
], ],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
expect(r.hasTransfers).toBe(true); expect(r.hasTransfers).toBe(true);
// before = income expense // before = income expense
@ -99,11 +66,11 @@ describe("computeOverTimeResults", () => {
it("collapses net to before-transfers when there are no transfers", () => { it("collapses net to before-transfers when there are no transfers", () => {
const data = makeData( const data = makeData(
["2025-01"], [{ month: "2025-01", Salary: 2000, Rent: 2500 }],
[leaf(1, "Salary", [2000], "income"), leaf(2, "Rent", [2500], "expense")], { Salary: "income", Rent: "expense" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent"]);
expect(r.hasTransfers).toBe(false); expect(r.hasTransfers).toBe(false);
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]); expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
@ -113,27 +80,24 @@ describe("computeOverTimeResults", () => {
expect(r.net.total).toBe(-500); expect(r.net.total).toBe(-500);
}); });
it("defaults a leaf with an unknown type to the expense section (defensive)", () => { it("defaults an untyped category (e.g. Other) to expense", () => {
// The tree normally stamps a valid type on every leaf; the reducer still const data = makeData(
// guards against a stray value by folding it into expenses. [{ month: "2025-01", Salary: 1000, Other: 300 }],
const untyped = { { Salary: "income" }, // Other has no type entry
...leaf(9, "Other", [300], "expense"), );
category_type: undefined as unknown as "expense",
};
const data = makeData(["2025-01"], [leaf(1, "Salary", [1000], "income"), untyped]);
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Other"]);
const expense = r.sections.find((s) => s.type === "expense")!; const expense = r.sections.find((s) => s.type === "expense")!;
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Other"]); expect(expense.categories).toEqual(["Other"]);
expect(expense.total).toBe(300); expect(expense.total).toBe(300);
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300 expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300
}); });
it("drops empty sections and still nets a lone transfer section", () => { it("drops empty sections and still nets a lone transfer section", () => {
const data = makeData(["2025-01"], [leaf(1, "Move", [100], "transfer")]); const data = makeData([{ month: "2025-01", Move: 100 }], { Move: "transfer" });
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Move"]);
expect(r.sections.map((s) => s.type)).toEqual(["transfer"]); expect(r.sections.map((s) => s.type)).toEqual(["transfer"]);
expect(r.hasTransfers).toBe(true); expect(r.hasTransfers).toBe(true);
@ -141,51 +105,29 @@ describe("computeOverTimeResults", () => {
expect(r.net.monthly["2025-01"]).toBe(100); expect(r.net.monthly["2025-01"]).toBe(100);
}); });
it("keeps homonym categories of different types apart (Résultat net correct) — Issue #264", () => { it("only counts the categories passed as visible (hidden ones excluded)", () => {
// Two DIFFERENT categories both named "Divers": one income, one expense.
// A name-keyed pivot would merge them into a single cell and a single type;
// the id-keyed tree keeps them distinct, so the net stays correct.
const data = makeData( const data = makeData(
["2025-01"], [{ month: "2025-01", Salary: 2000, Rent: 500, Hidden: 999 }],
[leaf(1, "Divers", [1000], "income"), leaf(2, "Divers", [300], "expense")], { Salary: "income", Rent: "expense", Hidden: "expense" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent"]); // Hidden filtered out upstream
const income = r.sections.find((s) => s.type === "income")!;
const expense = r.sections.find((s) => s.type === "expense")!;
expect(income.total).toBe(1000);
expect(expense.total).toBe(300);
// Net = 1000 300 = 700, NOT 0 (which a name collision would produce).
expect(r.net.monthly["2025-01"]).toBe(700);
});
it("sums leaves only — a parent subtotal row is never double-counted — Issue #264", () => {
// The tree carries both a parent subtotal (is_parent) and its leaves. The
// reducer must add up leaves only, or the parent's 800 would be counted twice.
const parent = { ...leaf(10, "Dépenses", [800], "expense"), is_parent: true };
const child1 = { ...leaf(11, "Épicerie", [500], "expense"), parent_id: 10, depth: 1 };
const child2 = { ...leaf(12, "Restaurant", [300], "expense"), parent_id: 10, depth: 1 };
const income = leaf(1, "Salary", [2000], "income");
const data = makeData(["2025-01"], [income, parent, child1, child2]);
const r = computeOverTimeResults(data);
const expense = r.sections.find((s) => s.type === "expense")!; const expense = r.sections.find((s) => s.type === "expense")!;
// 500 + 300 = 800 (the parent's own 800 subtotal is excluded). expect(expense.total).toBe(500);
expect(expense.total).toBe(800); expect(r.net.monthly["2025-01"]).toBe(1500); // 2000 500
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Épicerie", "Restaurant"]);
// Net = 2000 800 = 1200, not 2000 1600.
expect(r.net.monthly["2025-01"]).toBe(1200);
}); });
it("treats a category absent from a month as zero", () => { it("treats a category absent from a month as zero", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"], [
[leaf(1, "Salary", [1000, 1000], "income"), leaf(2, "Bonus", [0, 500], "income")], { month: "2025-01", Salary: 1000 },
{ month: "2025-02", Salary: 1000, Bonus: 500 },
],
{ Salary: "income", Bonus: "income" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Bonus"]);
const income = r.sections.find((s) => s.type === "income")!; const income = r.sections.find((s) => s.type === "income")!;
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 }); expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });

View file

@ -1,12 +1,12 @@
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; import type { CategoryOverTimeData, CategoryOverTimeItem } from "../../shared/types";
export type OverTimeType = "income" | "expense" | "transfer"; export type OverTimeType = "income" | "expense" | "transfer";
/** /**
* Income-statement reading order: revenue first, then expenses, then transfers, * Income-statement reading order: revenue first, then expenses, then transfers,
* so the "net result" row reads naturally at the bottom (Income Expenses + * so the "net result" row reads naturally at the bottom (Income Expenses +
* Transfers). Matches the compare report's `COMPARE_TYPE_ORDER`, which is also * Transfers). Note this is income-first, unlike the compare report's
* income-first since issue #253 (the earlier expense-first note is obsolete). * `COMPARE_TYPE_ORDER` (expense-first) see issue #256.
*/ */
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = { export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
income: 0, income: 0,
@ -22,18 +22,10 @@ export interface OverTimeSeries {
total: number; total: number;
} }
/** /** One rendered section: a type, its member categories, and per-month + total subtotals. */
* One rendered section: a type, its member leaf rows (id-keyed, from the tree), export interface OverTimeSection extends OverTimeSeries {
* and per-month + total subtotals. `rows` are the actual leaves in the section
* (never subtotal rows), each carrying its own index-aligned `monthly[]`.
*/
export interface OverTimeSection {
type: OverTimeType; type: OverTimeType;
rows: OverTimeRow[]; categories: string[];
/** month (YYYY-MM) -> section subtotal */
monthly: Record<string, number>;
/** sum across every month */
total: number;
} }
export interface OverTimeAnalysis { export interface OverTimeAnalysis {
@ -49,53 +41,60 @@ export interface OverTimeAnalysis {
beforeTransfers: OverTimeSeries; beforeTransfers: OverTimeSeries;
} }
/** /** Reads a numeric category cell off a pivot row, treating anything non-numeric as 0. */
* Pure reducer that turns the category-over-time report into an "income function cellValue(item: CategoryOverTimeItem | undefined, category: string): number {
* statement" shape: per-type sections with per-month subtotals, plus the const v = (item as Record<string, unknown> | undefined)?.[category];
* net-result rows. return typeof v === "number" ? v : 0;
*
* Consumes the **id-keyed tree** (`data.tree`) NOT the name-keyed pivot so
* two homonym categories of different types never collide: each leaf carries its
* own `category_id`-resolved `category_type`, and the subtotals + result lines
* are summed over the tree's LEAVES only (never its `is_parent` subtotal rows,
* which would double-count their own descendants). Leaf `monthly[]` values are
* positive magnitudes (`ABS(SUM())` in SQL), so income/expense/transfer
* subtotals are all 0 and the net is `income expense + transfer`.
*
* The tree is already ordered income expense transfer, so section ordering
* falls out of the leaves' own `category_type`.
*/
export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnalysis {
const months = data.data.map((d) => d.month);
// Leaves only — subtotals (`is_parent`) are excluded so a parent group can
// never double-count against its own descendants.
const typeOf = (row: OverTimeRow): OverTimeType => {
const t = row.category_type;
return t === "income" || t === "transfer" ? t : "expense";
};
const byType: Record<OverTimeType, OverTimeRow[]> = { income: [], expense: [], transfer: [] };
for (const row of data.tree) {
if (!row.is_parent) byType[typeOf(row)].push(row);
} }
/**
* Pure reducer that turns the category-over-time pivot into an "income statement"
* shape: per-type sections with per-month subtotals, plus the net-result rows.
*
* Values coming from `getCategoryOverTime` are positive magnitudes (`ABS(SUM())`
* in SQL), so income/expense/transfer subtotals are all 0, and the net is
* `income expense + transfer`. A category whose type is unknown (e.g. the
* "Other" bucket that aggregates non-top-N categories) defaults to `expense`,
* mirroring the `COALESCE(c.type, 'expense')` used by the service.
*
* Extracted from `CategoryOverTimeTable` and unit-tested independently the
* project has no React render harness, so the arithmetic lives in a pure module.
*/
export function computeOverTimeResults(
data: CategoryOverTimeData,
visibleCategories: string[],
): OverTimeAnalysis {
const months = data.data.map((d) => d.month);
const itemByMonth = new Map<string, CategoryOverTimeItem>();
for (const item of data.data) itemByMonth.set(item.month, item);
const typeOf = (cat: string): OverTimeType => {
const t = data.types[cat];
return t === "income" || t === "transfer" ? t : "expense";
};
// Bucket visible categories by type, preserving their incoming (magnitude) order.
const byType: Record<OverTimeType, string[]> = { income: [], expense: [], transfer: [] };
for (const cat of visibleCategories) byType[typeOf(cat)].push(cat);
const buildSection = (type: OverTimeType): OverTimeSection => { const buildSection = (type: OverTimeType): OverTimeSection => {
const rows = byType[type]; const categories = byType[type];
const monthly: Record<string, number> = {}; const monthly: Record<string, number> = {};
let total = 0; let total = 0;
months.forEach((month, i) => { for (const month of months) {
const sum = rows.reduce((s, row) => s + (row.monthly[i] ?? 0), 0); const item = itemByMonth.get(month);
const sum = categories.reduce((s, cat) => s + cellValue(item, cat), 0);
monthly[month] = sum; monthly[month] = sum;
total += sum; total += sum;
}); }
return { type, rows, monthly, total }; return { type, categories, monthly, total };
}; };
const income = buildSection("income"); const income = buildSection("income");
const expense = buildSection("expense"); const expense = buildSection("expense");
const transfer = buildSection("transfer"); const transfer = buildSection("transfer");
const hasTransfers = transfer.rows.length > 0; const hasTransfers = transfer.categories.length > 0;
const net: OverTimeSeries = { monthly: {}, total: 0 }; const net: OverTimeSeries = { monthly: {}, total: 0 };
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 }; const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
@ -109,7 +108,7 @@ export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnal
} }
const sections = [income, expense, transfer] const sections = [income, expense, transfer]
.filter((s) => s.rows.length > 0) .filter((s) => s.categories.length > 0)
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]); .sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
return { months, sections, hasTransfers, net, beforeTransfers }; return { months, sections, hasTransfers, net, beforeTransfers };

View file

@ -1,188 +0,0 @@
import { describe, it, expect } from "vitest";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { visibleRows } from "../../utils/collapsibleRows";
import { computeOverTimeResults } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
} from "./overTimeTableModel";
type RowType = "income" | "expense" | "transfer";
/** Builds one id-keyed tree row; `monthly` is index-aligned to the months list. */
function mkRow(
categoryId: number,
categoryName: string,
monthly: number[],
category_type: RowType,
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
}
const MONTHS = ["2025-01", "2025-02"];
// A realistic parent-first depth-first tree (income → expense → transfer):
// Revenus (parent, id 1)
// Paie (leaf, id 11)
// Bonus (leaf, id 12)
// Dépenses (parent, id 2)
// Épicerie (leaf, id 21)
// Resto (leaf, id 22)
// Loyer (top-level leaf, id 23)
// Épargne (transfer leaf, id 3)
function buildTree(): OverTimeRow[] {
return [
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }),
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }),
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
];
}
const acc = OVERTIME_COLLAPSE_ACCESSORS;
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
describe("overTimeTableModel — storage key", () => {
it("uses a trends-specific key, distinct from the comparable tables", () => {
expect(OVERTIME_EXPANDED_STORAGE_KEY).toBe("reports-trends-expanded");
expect(OVERTIME_EXPANDED_STORAGE_KEY).not.toBe("reports-compare-expanded");
});
});
describe("overTimeTableModel — collapse accessors", () => {
it("keys by category id and reads the snake_case hierarchy block", () => {
const parent = buildTree()[0];
const leaf = buildTree()[1];
expect(acc.keyOf(parent)).toBe("1");
expect(acc.depthOf(leaf)).toBe(1);
expect(acc.isParent(parent)).toBe(true);
expect(acc.isParent(leaf)).toBe(false);
});
});
describe("overTimeTableModel.groupOverTimeSections", () => {
it("splits the tree into ordered sections carrying the FULL hierarchy (parents + leaves)", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
// Income-first, transfer split out for interleaving.
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
expect(transferSection?.type).toBe("transfer");
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
const income = nonTransferSections.find((s) => s.type === "income")!;
expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(income.rows[0].is_parent).toBe(true);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
expect(expense.rows.map((r) => r.categoryName)).toEqual([
"Dépenses",
"Épicerie",
"Resto",
"Loyer",
]);
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
});
it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Paie 6000 + Bonus 500 = 6500 (the Revenus parent's own 6500 is NOT added again).
expect(income.total).toBe(6500);
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
const expense = nonTransferSections.find((s) => s.type === "expense")!;
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded).
expect(expense.total).toBe(3300);
});
it("drops empty sections: no transfers → transferSection is null", () => {
const tree = buildTree().filter((r) => r.category_type !== "transfer");
const data = makeData(MONTHS, tree);
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
expect(transferSection).toBeNull();
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
});
});
describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
it("is collapsed by default: only depth-0 rows survive an empty expanded set", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
// Revenus stays (its own subtotal row); Paie/Bonus are folded away.
expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
// Dépenses folds its children; the top-level leaf Loyer always stays.
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]);
});
it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
});
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Figures come from the raw tree (never the visible rows), so they hold in
// every collapse state.
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income 3300 expense
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
expect(income.total).toBe(6500);
// Folding vs expanding a group only changes how many rows are visible.
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(collapsed.length).toBeLessThan(expanded.length);
// The subtotal the table shows is identical in both states.
expect(income.total).toBe(6500);
});
});

View file

@ -1,89 +0,0 @@
import type { OverTimeRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
/**
* Render-model helpers for the hierarchical trends table (issue #265). Kept in a
* pure module the project has no React render harness, so the grouping and
* result-interleaving logic is unit-tested here while `CategoryOverTimeTable`
* stays a thin renderer.
*/
/**
* Collapse groups keyed by category id; depth/parent mirror the tree the render
* indents by, so a collapsed group hides exactly its indented descendants. The
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
*/
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
keyOf: (row) => String(row.categoryId),
depthOf: (row) => row.depth,
isParent: (row) => row.is_parent,
};
/** Distinct localStorage key so the trends expansion state never collides with
* the comparable tables' `reports-compare-expanded` (issue #265). */
export const OVERTIME_EXPANDED_STORAGE_KEY = "reports-trends-expanded";
/**
* One rendered section: its type, the FULL hierarchy rows of that type (parents
* + leaves, parent-first depth-first straight off `data.tree`), and the
* leaf-summed per-month + total subtotals carried over from the reducer.
*
* `rows` carries the parents (unlike `OverTimeSection.rows`, which is leaves
* only) so the table can render the hierarchy and collapse it; `monthly`/`total`
* stay the reducer's leaf sums, so they are unaffected by which rows are folded
* away the "collapse is purely visual" invariant.
*/
export interface OverTimeRenderSection {
type: OverTimeType;
rows: OverTimeRow[];
/** month (YYYY-MM) -> section subtotal (leaf-summed, collapse-invariant). */
monthly: Record<string, number>;
/** sum across every month (leaf-summed, collapse-invariant). */
total: number;
}
const typeOf = (row: OverTimeRow): OverTimeType => {
const t = row.category_type;
return t === "income" || t === "transfer" ? t : "expense";
};
/**
* Splits the id-keyed tree into render sections, driven by the reducer's own
* (ordered, non-empty, income expense transfer) `analysis.sections` so the
* ordering and the subtotals match `computeOverTimeResults` exactly. Each
* section is given the FULL hierarchy rows of its type (from `tree`), while its
* subtotals stay the reducer's leaf sums.
*
* Returns the non-transfer sections and the transfer section separately so the
* caller can interleave the "result before transfers" line between them
* (parity with `ComparePeriodTable`).
*/
export function groupOverTimeSections(
analysis: OverTimeAnalysis,
tree: OverTimeRow[],
): {
nonTransferSections: OverTimeRenderSection[];
transferSection: OverTimeRenderSection | null;
} {
// Full hierarchy rows (parents + leaves) bucketed by type, tree order kept.
const rowsByType: Record<OverTimeType, OverTimeRow[]> = {
income: [],
expense: [],
transfer: [],
};
for (const row of tree) rowsByType[typeOf(row)].push(row);
const sections: OverTimeRenderSection[] = analysis.sections.map((s) => ({
type: s.type,
rows: rowsByType[s.type],
monthly: s.monthly,
total: s.total,
}));
return {
nonTransferSections: sections.filter((s) => s.type !== "transfer"),
transferSection: sections.find((s) => s.type === "transfer") ?? null,
};
}

View file

@ -51,7 +51,7 @@ const yearStartStr = `${now.getFullYear()}-01-01`;
const initialState: DashboardState = { const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }, summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [], categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
budgetVsActual: [], budgetVsActual: [],
period: "year", period: "year",
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(), budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),

View file

@ -21,9 +21,9 @@ type Action =
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
const initialState: State = { const initialState: State = {
subView: "byCategory", subView: "global",
monthlyTrends: [], monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
isLoading: false, isLoading: false,
error: null, error: null,
}; };

View file

@ -23,7 +23,7 @@ export default function ReportsTrendsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends(); const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table")); const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType()); const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
@ -123,7 +123,7 @@ export default function ReportsTrendsPage() {
chartType={chartType} chartType={chartType}
/> />
) : ( ) : (
<CategoryOverTimeTable data={categoryOverTime} /> <CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
)} )}
</div> </div>
); );

View file

@ -6,9 +6,8 @@ import {
getCompareYearOverYear, getCompareYearOverYear,
getCategoryZoom, getCategoryZoom,
buildCompareTree, buildCompareTree,
buildOverTimeTree,
} from "./reportService"; } from "./reportService";
import type { CategoryDelta, OverTimeRow } from "../shared/types"; import type { CategoryDelta } from "../shared/types";
// Mock the db module // Mock the db module
vi.mock("./db", () => { vi.mock("./db", () => {
@ -164,66 +163,23 @@ describe("getCategoryOverTime", () => {
expect(result.types).toEqual({ Food: "expense" }); expect(result.types).toEqual({ Food: "expense" });
}); });
it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => { it("groups non-top-N categories into Other (Other left out of the types map)", async () => {
mockSelect mockSelect
// 1. top categories — top-N = 1, so only Food.
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 }, { category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
]) ])
// 2. monthly breakdown — every category (no LIMIT here).
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }, { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 }, { month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 }, { month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
])
// 3. category metadata for the tree.
.mockResolvedValueOnce([
{ id: 1, name: "Food", color: "#ff0000", type: "expense", parent_id: null },
{ id: 2, name: "Transport", color: "#00ff00", type: "expense", parent_id: null },
{ id: 3, name: "Entertainment", color: "#0000ff", type: "expense", parent_id: null },
]); ]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense"); const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
// Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
expect(result.categories).toEqual(["Food", "Other"]); expect(result.categories).toEqual(["Food", "Other"]);
expect(result.colors["Other"]).toBe("#9ca3af"); expect(result.colors["Other"]).toBe("#9ca3af");
expect(result.types).toEqual({ Food: "expense" }); expect(result.types).toEqual({ Food: "expense" });
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 }); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
// Sidecar tree — EVERY category (no top-N, no "Other"), keyed by id.
const treeNames = result.tree.map((r) => r.categoryName);
expect(treeNames).toEqual(expect.arrayContaining(["Food", "Transport", "Entertainment"]));
expect(treeNames).not.toContain("Other");
const food = result.tree.find((r) => r.categoryId === 1)!;
expect(food.monthly).toEqual([300]);
expect(food.total).toBe(300);
expect(food.category_type).toBe("expense");
expect(food.is_parent).toBe(false);
});
it("separates homonym categories of different types in the tree (Issue #264)", async () => {
mockSelect
.mockResolvedValueOnce([]) // top categories (irrelevant to the tree)
.mockResolvedValueOnce([
// Two DIFFERENT categories both named "Divers".
{ month: "2025-01", category_id: 1, category_name: "Divers", total: 1000 },
{ month: "2025-01", category_id: 2, category_name: "Divers", total: 300 },
])
.mockResolvedValueOnce([
{ id: 1, name: "Divers", color: null, type: "income", parent_id: null },
{ id: 2, name: "Divers", color: null, type: "expense", parent_id: null },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31");
// The pivot merges them (single "Divers" cell); the tree keeps them apart.
const incomeDivers = result.tree.find((r) => r.categoryId === 1)!;
const expenseDivers = result.tree.find((r) => r.categoryId === 2)!;
expect(incomeDivers.category_type).toBe("income");
expect(incomeDivers.total).toBe(1000);
expect(expenseDivers.category_type).toBe("expense");
expect(expenseDivers.total).toBe(300);
}); });
}); });
@ -844,114 +800,6 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
}); });
}); });
describe("buildOverTimeTree — hierarchical trends (Issue #264)", () => {
/** Flat id-keyed leaf, mirroring what getCategoryOverTime feeds the builder. */
function otLeaf(categoryId: number | null, categoryName: string, monthly: number[]): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
}
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: 8, name: "Revenus", color: null, type: "income", parent_id: null },
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
{ 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 buildOverTimeTree>[1];
it("nests leaves under a parent subtotal equal to the per-month sum of its children", () => {
const rows = buildOverTimeTree(
[otLeaf(22, "Épicerie", [500, 400]), otLeaf(24, "Restaurant", [120, 200])],
cats,
2,
);
// Parent subtotal on top, then children ordered by |total| 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 series is index-aligned and equals the per-month child sums.
expect(parent.monthly).toEqual([620, 600]);
expect(parent.total).toBe(1220);
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
expect(epicerie.is_parent).toBe(false);
expect(epicerie.depth).toBe(1);
expect(epicerie.parent_id).toBe(2);
expect(epicerie.monthly).toEqual([500, 400]);
});
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
const rows = buildOverTimeTree(
[otLeaf(80, "Paie", [4200]), otLeaf(22, "Épicerie", [500]), otLeaf(50, "REER", [0])],
cats,
1,
);
// Each leaf sits under a parent, so every section yields [subtotal, leaf].
expect(rows.map((r) => r.category_type)).toEqual([
"income",
"income",
"expense",
"expense",
"transfer",
"transfer",
]);
});
it("preserves grand-total invariance vs the flat leaves", () => {
const flat = [
otLeaf(22, "Épicerie", [500, 400]),
otLeaf(24, "Restaurant", [120, 200]),
otLeaf(50, "REER", [0, 0]),
];
const rows = buildOverTimeTree(flat, cats, 2);
const sumLeaves = (rs: OverTimeRow[]) =>
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.total, 0);
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
});
it("surfaces an uncategorized (null id) leaf as a top-level row", () => {
const rows = buildOverTimeTree([otLeaf(null, "Uncategorized", [90])], cats, 1);
expect(rows).toHaveLength(1);
expect(rows[0].categoryName).toBe("Uncategorized");
expect(rows[0].is_parent).toBe(false);
expect(rows[0].depth).toBe(0);
expect(rows[0].monthly).toEqual([90]);
});
it("terminates on a cyclic parent_id chain (A → B → A) without overflowing", () => {
const cyclic = [
{ id: 1, name: "A", color: null, type: "expense", parent_id: 2 },
{ id: 2, name: "B", color: null, type: "expense", parent_id: 1 },
] as Parameters<typeof buildOverTimeTree>[1];
// The relevant-ancestor walk and buildNode both cap at MAX_TREE_DEPTH, so a
// corrupted cycle returns (no root emerges) instead of recursing forever —
// .not.toThrow() catches a "Maximum call stack" RangeError if it overflowed.
let rows: OverTimeRow[] = [];
expect(() => {
rows = buildOverTimeTree([otLeaf(1, "A", [100])], cyclic, 1);
}).not.toThrow();
expect(Array.isArray(rows)).toBe(true);
for (const r of rows) expect(r.depth).toBeLessThanOrEqual(5);
});
});
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => { describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
it("returns parent subtotal rows when category metadata is available", async () => { it("returns parent subtotal rows when category metadata is available", async () => {
mockSelect mockSelect

View file

@ -5,7 +5,6 @@ import type {
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
CategoryOverTimeItem, CategoryOverTimeItem,
OverTimeRow,
HighlightsData, HighlightsData,
HighlightMover, HighlightMover,
CategoryDelta, CategoryDelta,
@ -184,58 +183,12 @@ export async function getCategoryOverTime(
categories.push("Other"); 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 { return {
categories, categories,
data, data: Array.from(monthMap.values()),
colors, colors,
categoryIds, categoryIds,
types, types,
tree,
}; };
} }
@ -482,206 +435,6 @@ 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}` };
} }
// --- 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) --- // --- Compare hierarchy (Issue #247) ---
/** /**
@ -690,7 +443,13 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
* historic transactions keep their place in the hierarchy matching the raw * historic transactions keep their place in the hierarchy matching the raw
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown). * LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
*/ */
type CompareCatMeta = TreeCatMeta; 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_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
@ -698,6 +457,9 @@ const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM cat
// (netted) transfers — the two result lines are injected around them in the UI. // (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 }; const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 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 * 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 * parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
@ -706,12 +468,9 @@ const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, tran
* The #243 transfer netting is untouched: leaf values are exactly what the SQL * 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 * 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 * group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
* net. * 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
* A thin specialisation of `buildLeafDrivenTree` (Issue #263): the generic * soft-deleted categories with history are preserved and no empty rows appear.
* 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. * Exported for unit testing.
*/ */
@ -719,19 +478,65 @@ export function buildCompareTree(
leaves: CategoryDelta[], leaves: CategoryDelta[],
categories: CompareCatMeta[], categories: CompareCatMeta[],
): CategoryDelta[] { ): CategoryDelta[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense"; const catById = new Map<number, CompareCatMeta>();
for (const c of categories) catById.set(c.id, c);
return buildLeafDrivenTree<CategoryDelta>(leaves, { // Flat delta lookup by category id. Rows whose category id is null
categories, // (Uncategorized) or absent from the table (hard-deleted) become orphans:
categoryIdOf: (d) => d.categoryId, // depth-0 leaves preserved in their incoming order.
makeLeaf: (cat, d, parentId, depth) => ({ const deltaByCat = new Map<number, CategoryDelta>();
...d, 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, parent_id: parentId,
is_parent: false, is_parent: false,
depth, depth,
category_type: typeOf(cat), category_type: typeOf(cat),
}), });
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
const subtotalRow = (
cat: CompareCatMeta,
descendantLeaves: CategoryDelta[],
parentId: number | null,
depth: number,
): CategoryDelta => {
let previousAmount = 0; let previousAmount = 0;
let currentAmount = 0; let currentAmount = 0;
let cumulativePreviousAmount = 0; let cumulativePreviousAmount = 0;
@ -766,97 +571,81 @@ export function buildCompareTree(
depth, depth,
category_type: typeOf(cat), 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 interface Block {
// spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData). rows: CategoryDelta[];
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }), sortKey: number; // |monthly delta| of the block head — orders siblings
// Orphans (Uncategorized / hard-deleted) keep their own series; only the }
// hierarchy fields are stamped. No metadata → default 'expense'.
makeOrphan: (l) => ({ // Builds a node's block: a pure leaf, or a subtotal followed by its
...l, // (recursively built) child blocks, siblings ordered by |monthly delta| desc.
parent_id: null, const buildNode = (cat: CompareCatMeta, depth: number): Block | null => {
is_parent: false, // Stop descending past the depth cap so a corrupted parent_id cycle can
depth: 0, // never recurse forever — deeper nodes collapse to leaves (real category
category_type: l.category_type ?? "expense", // trees are ≤ 3 levels).
}), const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []);
sortKey: (row) => Math.abs(row.total), const hasDirect = deltaByCat.has(cat.id);
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense", if (children.length === 0) {
sectionOrder: COMPARE_TYPE_ORDER, 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 (income → expense → 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 };

View file

@ -367,31 +367,6 @@ export interface CategoryOverTimeItem {
[categoryName: string]: number | string; [categoryName: string]: number | string;
} }
/**
* One row of the id-keyed trends tree (Issue #264) the sidecar to the
* name-keyed pivot below. Carries a per-month magnitude series instead of a
* single delta, but mirrors `CategoryDelta`'s snake_case hierarchy block
* (`parent_id` / `is_parent` / `depth` / `category_type`) so it composes with
* `collapsibleRows` / `useCollapsibleGroups` without friction. Section/type is
* resolved by `category_id` (never by name), so two homonym categories of
* different types never collide the fix the name-keyed pivot cannot express.
*/
export interface OverTimeRow {
categoryId: number | null;
categoryName: string;
categoryColor: string;
/** Positive magnitude per month, index-aligned to `CategoryOverTimeData.data`. */
monthly: number[];
/** Sum of `monthly` — sibling ordering + row total convenience. */
total: number;
// Hierarchy block — same snake_case names as CategoryDelta (Issue #247/#264).
// `is_parent` rows are subtotals whose series = the sum of their leaf subtree.
parent_id: number | null;
is_parent: boolean;
depth: number;
category_type: "expense" | "income" | "transfer";
}
export interface CategoryOverTimeData { export interface CategoryOverTimeData {
categories: string[]; categories: string[];
data: CategoryOverTimeItem[]; data: CategoryOverTimeItem[];
@ -399,13 +374,6 @@ export interface CategoryOverTimeData {
categoryIds: Record<string, number | null>; categoryIds: Record<string, number | null>;
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */ /** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
types: Record<string, "expense" | "income" | "transfer">; types: Record<string, "expense" | "income" | "transfer">;
/**
* Id-keyed hierarchical sidecar (Issue #264): EVERY category (no top-N, no
* "Other" bucket), parent-first depth-first, income expense transfer.
* Drives the trends table + its result lines. The pivot fields above are left
* unchanged and still drive the (top-N-capped) chart and the dashboard.
*/
tree: OverTimeRow[];
} }
export interface BudgetVsActualRow { export interface BudgetVsActualRow {