feat(budget) : adopter le repli multi-niveaux sur la grille Budget #293

Open
maximus wants to merge 1 commit from issue-289-budget-collapse into issue-288-collapse-socle
5 changed files with 501 additions and 294 deletions
Showing only changes of commit 9c325e274b - Show all commits

View file

@ -5,6 +5,7 @@
### Modifié ### Modifié
- Rapports : les trois tableaux de rapport hiérarchiques (Comparaison réel-vs-réel, réel-vs-budget, et Tendances par catégorie) permettent désormais de **replier ou déplier chaque niveau de la hiérarchie de catégories**, plus seulement le premier. Chaque catégorie parente — y compris les intermédiaires — porte son propre chevron ; en déplier une révèle ses enfants directs (eux-mêmes repliés), pour descendre un niveau à la fois. Les tableaux s'ouvrent entièrement repliés (seules les catégories de premier niveau visibles), « Tout déplier » ouvre tous les niveaux d'un coup, et vos choix de repli sont désormais enregistrés **par profil** — dans la base de données du profil, donc détruits avec lui — au lieu du navigateur. Les sous-totaux et les lignes de résultat restent rigoureusement inchangés quel que soit ce que vous repliez (#288). - Rapports : les trois tableaux de rapport hiérarchiques (Comparaison réel-vs-réel, réel-vs-budget, et Tendances par catégorie) permettent désormais de **replier ou déplier chaque niveau de la hiérarchie de catégories**, plus seulement le premier. Chaque catégorie parente — y compris les intermédiaires — porte son propre chevron ; en déplier une révèle ses enfants directs (eux-mêmes repliés), pour descendre un niveau à la fois. Les tableaux s'ouvrent entièrement repliés (seules les catégories de premier niveau visibles), « Tout déplier » ouvre tous les niveaux d'un coup, et vos choix de repli sont désormais enregistrés **par profil** — dans la base de données du profil, donc détruits avec lui — au lieu du navigateur. Les sous-totaux et les lignes de résultat restent rigoureusement inchangés quel que soit ce que vous repliez (#288).
- Budget : la grille budget se **replie et se déplie elle aussi à chaque niveau de catégorie**, à l'image des rapports. Chaque catégorie parente porte un chevron, la grille **s'ouvre entièrement repliée** (seules les catégories de premier niveau visibles) pour un aperçu compact, et « Tout déplier » ouvre tous les niveaux en un clic. Replier une catégorie est purement visuel — les totaux de section et les lignes de résultat sont inchangés, et aucune valeur de budget que vous avez saisie n'est jamais perdue. Vos choix de repli sont enregistrés par profil (#289).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12

View file

@ -5,6 +5,7 @@
### Changed ### Changed
- Reports: the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, and Trends by category) now let you **collapse or expand every level of the category hierarchy**, not just the top level. Every parent category — including the intermediate ones — carries its own chevron; expanding one reveals its direct children (themselves collapsed), so you drill down one level at a time. The tables open fully collapsed (only the top-level categories visible), "Expand all" opens every level at once, and your collapse choices are now saved **per profile** — in the profile's own database, so they are destroyed with the profile — instead of in the browser. Subtotals and result lines stay exactly the same whatever you fold away (#288). - Reports: the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, and Trends by category) now let you **collapse or expand every level of the category hierarchy**, not just the top level. Every parent category — including the intermediate ones — carries its own chevron; expanding one reveals its direct children (themselves collapsed), so you drill down one level at a time. The tables open fully collapsed (only the top-level categories visible), "Expand all" opens every level at once, and your collapse choices are now saved **per profile** — in the profile's own database, so they are destroyed with the profile — instead of in the browser. Subtotals and result lines stay exactly the same whatever you fold away (#288).
- Budget: the budget grid now **collapses and expands at every category level** too, matching the reports. Each parent category carries a chevron, the grid **opens fully collapsed** (only the top-level categories visible) for a compact overview, and "Expand all" opens every level in one click. Folding a category is purely visual — section totals and the result lines are unchanged, and no budget figure you have typed is ever lost. Your collapse choices are saved per profile (#289).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12

View file

@ -1,9 +1,11 @@
import { useState, useRef, useEffect, Fragment } from "react"; import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react"; import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults"; import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -18,7 +20,22 @@ const MONTH_KEYS = [
"months.sep", "months.oct", "months.nov", "months.dec", "months.sep", "months.oct", "months.nov", "months.dec",
] as const; ] as const;
const STORAGE_KEY = "subtotals-position"; // Prefixed so the "subtotals on top/bottom" preference is INDEPENDENT of the
// real-vs-budget report's (BudgetVsActualTable still uses "subtotals-position") —
// they collided before (issue #289).
const STORAGE_KEY = "budget-subtotals-position";
// Collapse groups keyed by category id; a row is hidden when any ancestor
// (walking parent_id) is collapsed, so every parent level is collapsible.
// Declared inline, mirroring ComparePeriodTable / BudgetVsActualTable (issue #289).
const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
keyOf: (row) => `p:${row.category_id}`,
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent,
};
const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
interface BudgetTableProps { interface BudgetTableProps {
rows: BudgetYearRow[]; rows: BudgetYearRow[];
@ -43,6 +60,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
return next; return next;
}); });
}; };
// Collapse/expand at every hierarchy level — collapsed by default (issue #289),
// matching the hierarchical reports. Persisted per profile in user_preferences.
const groups = useCollapsibleGroups<BudgetYearRow>(
BUDGET_EXPANDED_KEY,
BUDGET_COLLAPSE_ACCESSORS,
{ defaultExpanded: false },
);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const annualInputRef = useRef<HTMLInputElement>(null); const annualInputRef = useRef<HTMLInputElement>(null);
@ -173,24 +199,37 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`; const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
if (row.is_parent) { if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background // Parent subtotal row: read-only, bold, distinct background. Collapsible
// at every level (issue #289) — a chevron toggles its subtree.
const parentDepth = row.depth ?? 0; const parentDepth = row.depth ?? 0;
const isTopParent = parentDepth === 0; const isTopParent = parentDepth === 0;
const isIntermediateParent = parentDepth >= 1; const isIntermediateParent = parentDepth >= 1;
const collapsed = groups.isCollapsed(row);
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3"; const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
return ( return (
<tr <tr
key={rowKey} key={rowKey}
aria-level={parentDepth + 1}
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`} className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
> >
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}> <td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
<div className="flex items-center gap-2"> <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 <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.category_color }} style={{ backgroundColor: row.category_color }}
/> />
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span> <span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
</div> </button>
</td> </td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}> <td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
{formatSigned(row.previousYearTotal)} {formatSigned(row.previousYearTotal)}
@ -211,6 +250,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
return ( return (
<tr <tr
key={rowKey} key={rowKey}
aria-level={depth + 1}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors" className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
> >
{/* Category name - sticky */} {/* Category name - sticky */}
@ -297,18 +337,11 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
const renderTypeSection = (type: (typeof typeOrder)[number]) => { const renderTypeSection = (type: (typeof typeOrder)[number]) => {
const group = grouped[type]; const group = grouped[type];
if (!group || group.length === 0) return null; if (!group || group.length === 0) return null;
const sign = signFor(type); // Section subtotal is summed from the RAW group via the tested
const leaves = group.filter((r) => !r.is_parent); // `sumLeavesForType` (leaves only, sign applied to budgeted figures), never
const sectionMonthTotals: number[] = Array(12).fill(0); // from the collapse-filtered rows — folding a parent stays purely visual and
let sectionAnnualTotal = 0; // never moves a total (issue #289).
let sectionPrevYearTotal = 0; const sectionTotals = sumLeavesForType(group, type);
for (const row of leaves) {
for (let m = 0; m < 12; m++) {
sectionMonthTotals[m] += row.months[m] * sign;
}
sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
return ( return (
<Fragment key={type}> <Fragment key={type}>
<tr> <tr>
@ -319,14 +352,14 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{t(typeLabelKeys[type])} {t(typeLabelKeys[type])}
</td> </td>
</tr> </tr>
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))} {reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]"> <tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold"> <td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])} {t(typeTotalKeys[type])}
</td> </td>
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td> <td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionTotals.previousYearTotal)}</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td> <td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionTotals.annual)}</td>
{sectionMonthTotals.map((total, mIdx) => ( {sectionTotals.months.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold"> <td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
{formatSigned(total)} {formatSigned(total)}
</td> </td>
@ -362,9 +395,22 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
); );
}; };
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return ( return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"> <div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
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>
)}
<button <button
onClick={toggleSubtotals} 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" 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"

130
src/hooks/useBudget.test.ts Normal file
View file

@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import type { BudgetYearRow, Category, BudgetEntry } from "../shared/types";
import { buildBudgetYearRows } from "./useBudget";
import { sumLeavesForType } from "../components/budget/budgetTableResults";
import {
type CollapseAccessors,
visibleRows,
isCollapsedFor,
} from "../utils/collapsibleRows";
// The Budget grid's rows are built by `useBudget` (the "3rd builder" the
// abandoned v1 collapse plan ignored). Its final sort is LEVEL-ORDER (depth
// ascending / BFS), not depth-first. These tests pin that invariant: the
// shipped ancestor-walk collapse (issue #288) is order-INDEPENDENT, but the v1
// depth-cursor algorithm assumed DFS and would have broken exactly here.
function cat(
id: number,
name: string,
type: Category["type"],
opts: { parent_id?: number; is_inputable?: boolean; sort_order?: number } = {},
): Category {
return {
id,
name,
type,
parent_id: opts.parent_id,
color: "#000",
is_active: true,
is_inputable: opts.is_inputable ?? true,
sort_order: opts.sort_order ?? 0,
created_at: "",
};
}
// income leaf + a 3-level expense group:
// Housing (root, non-inputable)
// ├─ Rent (depth-1 leaf)
// └─ Utilities (depth-1 intermediate parent)
// ├─ Hydro (depth-2 leaf)
// └─ Internet (depth-2 leaf)
const CATEGORIES: Category[] = [
cat(10, "Salary", "income", { sort_order: 0 }),
cat(1, "Housing", "expense", { is_inputable: false, sort_order: 1 }),
cat(2, "Rent", "expense", { parent_id: 1, sort_order: 0 }),
cat(3, "Utilities", "expense", { parent_id: 1, is_inputable: false, sort_order: 1 }),
cat(4, "Hydro", "expense", { parent_id: 3, sort_order: 0 }),
cat(5, "Internet", "expense", { parent_id: 3, sort_order: 1 }),
];
function entry(category_id: number, amount: number, month = 1): BudgetEntry {
return { id: 0, category_id, year: 2026, month, amount, created_at: "", updated_at: "" };
}
// Same accessors BudgetTable declares inline — duplicated here (they are 4 trivial
// lambdas the issue mandates stay inline; a .tsx import into a node-env test would
// pull in JSX/lucide/react-i18next needlessly).
const ACCESSORS: CollapseAccessors<BudgetYearRow> = {
keyOf: (row) => `p:${row.category_id}`,
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent,
};
describe("buildBudgetYearRows — level-order emission (issue #289 invariant)", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []);
it("emits income before expense, then each group in LEVEL order (BFS), not DFS", () => {
// Salary (income) first; then the Housing expense group depth-ascending:
// root subtotal, then the depth-1 rows (parent Utilities before leaf Rent),
// then the depth-2 grandchildren.
expect(rows.map((r) => r.category_id)).toEqual([10, 1, 3, 2, 4, 5]);
});
it("keeps depth non-decreasing within a top group — the property that would have killed the v1 (DFS-assuming) collapse algorithm", () => {
const expenseGroup = rows.filter((r) => r.category_type === "expense");
const depths = expenseGroup.map((r) => r.depth ?? 0);
for (let i = 1; i < depths.length; i++) {
expect(depths[i]).toBeGreaterThanOrEqual(depths[i - 1]);
}
// Explicit DFS-killer: the depth-2 grandchildren (Hydro/Internet) come AFTER
// the depth-1 leaf sibling (Rent). A depth-first emission would interleave
// them directly under Utilities, i.e. BEFORE Rent.
const leafIdx = (id: number) => rows.findIndex((r) => r.category_id === id && !r.is_parent);
expect(leafIdx(4)).toBeGreaterThan(leafIdx(2)); // Hydro after Rent
expect(leafIdx(5)).toBeGreaterThan(leafIdx(2)); // Internet after Rent
});
});
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []);
it("collapsed-by-default (empty set) shows only the roots", () => {
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(new Set<string>(), ACCESSORS.keyOf(r), false);
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
// Salary (root leaf) + Housing (root subtotal); nothing beneath Housing.
expect(visible.map((r) => r.category_id)).toEqual([10, 1]);
});
it("expanding a root reveals its DIRECT children only — grandchildren stay folded under the still-collapsed intermediate", () => {
const flipped = new Set<string>(["p:1"]); // Housing expanded
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(flipped, ACCESSORS.keyOf(r), false);
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
// Housing's direct children: Utilities subtotal + Rent leaf. Hydro/Internet
// remain hidden under the collapsed Utilities.
expect(visible.map((r) => r.category_id)).toEqual([10, 1, 3, 2]);
});
});
describe("BudgetTable section subtotals stay on RAW rows (collapse is purely visual, issue #289)", () => {
const ENTRIES = [entry(2, 1000), entry(4, 100), entry(5, 50)];
const rows = buildBudgetYearRows(CATEGORIES, ENTRIES, []);
it("sums every expense LEAF regardless of collapse — folding a parent never moves a total", () => {
const rawExpense = sumLeavesForType(rows, "expense");
expect(rawExpense.annual).toBe(-1150); // -(Rent 1000 + Hydro 100 + Internet 50)
// If the grid mistakenly summed the collapse-visible rows, a fully-collapsed
// grid (only roots visible, and the expense root is a parent → excluded)
// would report 0. The grid feeds the RAW group to `sumLeavesForType`, so the
// section total is invariant under collapse; this asserts the two differ,
// which is exactly why raw rows must be used.
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(new Set<string>(), ACCESSORS.keyOf(r), false);
const collapsedVisible = visibleRows(rows, ACCESSORS, isCollapsed);
const visibleExpense = sumLeavesForType(collapsedVisible, "expense");
expect(visibleExpense.annual).toBe(0);
expect(rawExpense.annual).not.toBe(visibleExpense.annual);
});
});

View file

@ -1,5 +1,5 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types"; import type { BudgetYearRow, BudgetTemplate, ImportSource, Category, BudgetEntry } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
@ -73,6 +73,304 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253). // (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 }; const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/**
* Assembles the flat, hierarchical `BudgetYearRow[]` the grid renders from the
* raw category tree, this year's budget entries, and last year's actual totals.
*
* ORDERING INVARIANT (issue #289 the multi-level-collapse consumer). The
* final `rows.sort` below orders each top category group by DEPTH ASCENDING
* (level-order / BFS), NOT depth-first: within a group every depth-0 row
* precedes every depth-1 row, which precedes every depth-2 row. The abandoned
* v1 collapse algorithm wrongly assumed a depth-first emission order and broke
* exactly here; the shipped algorithm (ancestor walk see `collapsibleRows.ts`)
* is order-INDEPENDENT, so this sort needs NO change. The level-order property
* is pinned by `useBudget.test.ts` so a refactor can't silently reintroduce the
* v1 assumption do not reorder into DFS without revisiting the collapse
* consumer (`BudgetTable`).
*
* Pure (no DB, no React) so the invariant is unit-testable without a renderer
* the repo has no jsdom, so hook logic that must be tested is extracted here,
* the same way `useCompare.ts` exposes its pure helpers.
*/
export function buildBudgetYearRows(
allCategories: Category[],
entries: BudgetEntry[],
prevYearActuals: Array<{ category_id: number | null; actual: number }>,
): BudgetYearRow[] {
// Build a map: categoryId -> month(1-12) -> amount
const entryMap = new Map<number, Map<number, number>>();
for (const e of entries) {
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
entryMap.get(e.category_id)!.set(e.month, e.amount);
}
// Build a map for previous year actuals: categoryId -> annual actual total
// Amounts are already signed (expenses negative, income positive) — stored as-is.
const prevYearTotalMap = new Map<number, number>();
for (const a of prevYearActuals) {
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
}
// Helper: build months array from entryMap
const buildMonths = (catId: number) => {
const monthMap = entryMap.get(catId);
const months: number[] = [];
let annual = 0;
for (let m = 1; m <= 12; m++) {
const val = monthMap?.get(m) ?? 0;
months.push(val);
annual += val;
}
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
return { months, annual, previousYearTotal };
};
// Index categories by id and group children by parent_id
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, typeof allCategories>();
for (const cat of allCategories) {
if (cat.parent_id) {
if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []);
childrenByParent.get(cat.parent_id)!.push(cat);
}
}
const rows: BudgetYearRow[] = [];
// Build rows for an intermediate parent (level 1 or 2 with children)
function buildLevel2Group(cat: typeof allCategories[0], grandparentId: number): BudgetYearRow[] {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at depth 2
const { months, annual, previousYearTotal } = buildMonths(cat.id);
return [{
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: grandparentId,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
}];
}
if (grandchildren.length === 0 && !cat.is_inputable) {
// Also check if it has non-inputable intermediate children with their own children
// This shouldn't happen at depth 3 (max 3 levels), but handle gracefully
return [];
}
const gcRows: BudgetYearRow[] = [];
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
gcRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: cat.id,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
});
}
for (const gc of grandchildren) {
const { months, annual, previousYearTotal } = buildMonths(gc.id);
gcRows.push({
category_id: gc.id,
category_name: gc.name,
category_color: gc.color || cat.color || "#9ca3af",
category_type: gc.type,
parent_id: cat.id,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
});
}
if (gcRows.length === 0) return [];
// Build intermediate subtotal
const subMonths = Array(12).fill(0) as number[];
let subAnnual = 0;
let subPrevYear = 0;
for (const cr of gcRows) {
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
subAnnual += cr.annual;
subPrevYear += cr.previousYearTotal;
}
const subtotal: BudgetYearRow = {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: grandparentId,
is_parent: true,
depth: 1,
months: subMonths,
annual: subAnnual,
previousYearTotal: subPrevYear,
};
gcRows.sort((a, b) => {
if (a.category_id === cat.id) return -1;
if (b.category_id === cat.id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return [subtotal, ...gcRows];
}
// Identify top-level parents and standalone leaves
const topLevel = allCategories.filter((c) => !c.parent_id);
for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || [];
const inputableChildren = children.filter((c) => c.is_inputable);
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf (no children) — regular editable row
const { months, annual, previousYearTotal } = buildMonths(cat.id);
rows.push({
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: null,
is_parent: false,
depth: 0,
months,
annual,
previousYearTotal,
});
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
allChildRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: cat.id,
is_parent: false,
depth: 1,
months,
annual,
previousYearTotal,
});
}
for (const child of inputableChildren) {
const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) {
// Simple leaf at depth 1
const { months, annual, previousYearTotal } = buildMonths(child.id);
allChildRows.push({
category_id: child.id,
category_name: child.name,
category_color: child.color || cat.color || "#9ca3af",
category_type: child.type,
parent_id: cat.id,
is_parent: false,
depth: 1,
months,
annual,
previousYearTotal,
});
} else {
// Intermediate parent at depth 1 with grandchildren
allChildRows.push(...buildLevel2Group(child, cat.id));
}
}
// Non-inputable intermediate parents
for (const ip of intermediateParents) {
allChildRows.push(...buildLevel2Group(ip, cat.id));
}
if (allChildRows.length === 0) continue;
// Parent subtotal row: sum of leaf rows only (avoid double-counting)
const leafRows = allChildRows.filter((r) => !r.is_parent);
const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0;
let parentPrevYear = 0;
for (const cr of leafRows) {
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual;
parentPrevYear += cr.previousYearTotal;
}
rows.push({
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: null,
is_parent: true,
depth: 0,
months: parentMonths,
annual: parentAnnual,
previousYearTotal: parentPrevYear,
});
// Sort children alphabetically, but keep "(direct)" first
allChildRows.sort((a, b) => {
if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...allChildRows);
}
// else: non-inputable parent with no inputable children — skip
}
// Sort by type, then within each type: keep hierarchy groups together
function getTopGroupId(r: BudgetYearRow): number {
if ((r.depth ?? 0) === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
let pid = r.parent_id;
while (pid !== null) {
const pCat = catById.get(pid);
if (!pCat || !pCat.parent_id) return pid;
pid = pCat.parent_id;
}
return r.category_id;
}
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB;
const groupA = getTopGroupId(a);
const groupB = getTopGroupId(b);
if (groupA !== groupB) {
const catA = catById.get(groupA);
const catB = catById.get(groupB);
const orderA = catA?.sort_order ?? 999;
const orderB = catB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
}
// Same group: sort by depth, then parent before children at same depth
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
if (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.parent_id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return rows;
}
export function useBudget() { export function useBudget() {
const { accountIds } = useReportsPeriod(); const { accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, undefined, initialState); const [state, dispatch] = useReducer(reducer, undefined, initialState);
@ -93,276 +391,7 @@ export function useBudget() {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
// Build a map: categoryId -> month(1-12) -> amount const rows = buildBudgetYearRows(allCategories, entries, prevYearActuals);
const entryMap = new Map<number, Map<number, number>>();
for (const e of entries) {
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
entryMap.get(e.category_id)!.set(e.month, e.amount);
}
// Build a map for previous year actuals: categoryId -> annual actual total
// Amounts are already signed (expenses negative, income positive) — stored as-is.
const prevYearTotalMap = new Map<number, number>();
for (const a of prevYearActuals) {
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
}
// Helper: build months array from entryMap
const buildMonths = (catId: number) => {
const monthMap = entryMap.get(catId);
const months: number[] = [];
let annual = 0;
for (let m = 1; m <= 12; m++) {
const val = monthMap?.get(m) ?? 0;
months.push(val);
annual += val;
}
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
return { months, annual, previousYearTotal };
};
// Index categories by id and group children by parent_id
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, typeof allCategories>();
for (const cat of allCategories) {
if (cat.parent_id) {
if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []);
childrenByParent.get(cat.parent_id)!.push(cat);
}
}
const rows: BudgetYearRow[] = [];
// Build rows for an intermediate parent (level 1 or 2 with children)
function buildLevel2Group(cat: typeof allCategories[0], grandparentId: number): BudgetYearRow[] {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at depth 2
const { months, annual, previousYearTotal } = buildMonths(cat.id);
return [{
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: grandparentId,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
}];
}
if (grandchildren.length === 0 && !cat.is_inputable) {
// Also check if it has non-inputable intermediate children with their own children
// This shouldn't happen at depth 3 (max 3 levels), but handle gracefully
return [];
}
const gcRows: BudgetYearRow[] = [];
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
gcRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: cat.id,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
});
}
for (const gc of grandchildren) {
const { months, annual, previousYearTotal } = buildMonths(gc.id);
gcRows.push({
category_id: gc.id,
category_name: gc.name,
category_color: gc.color || cat.color || "#9ca3af",
category_type: gc.type,
parent_id: cat.id,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
});
}
if (gcRows.length === 0) return [];
// Build intermediate subtotal
const subMonths = Array(12).fill(0) as number[];
let subAnnual = 0;
let subPrevYear = 0;
for (const cr of gcRows) {
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
subAnnual += cr.annual;
subPrevYear += cr.previousYearTotal;
}
const subtotal: BudgetYearRow = {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: grandparentId,
is_parent: true,
depth: 1,
months: subMonths,
annual: subAnnual,
previousYearTotal: subPrevYear,
};
gcRows.sort((a, b) => {
if (a.category_id === cat.id) return -1;
if (b.category_id === cat.id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return [subtotal, ...gcRows];
}
// Identify top-level parents and standalone leaves
const topLevel = allCategories.filter((c) => !c.parent_id);
for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || [];
const inputableChildren = children.filter((c) => c.is_inputable);
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf (no children) — regular editable row
const { months, annual, previousYearTotal } = buildMonths(cat.id);
rows.push({
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: null,
is_parent: false,
depth: 0,
months,
annual,
previousYearTotal,
});
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
allChildRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: cat.id,
is_parent: false,
depth: 1,
months,
annual,
previousYearTotal,
});
}
for (const child of inputableChildren) {
const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) {
// Simple leaf at depth 1
const { months, annual, previousYearTotal } = buildMonths(child.id);
allChildRows.push({
category_id: child.id,
category_name: child.name,
category_color: child.color || cat.color || "#9ca3af",
category_type: child.type,
parent_id: cat.id,
is_parent: false,
depth: 1,
months,
annual,
previousYearTotal,
});
} else {
// Intermediate parent at depth 1 with grandchildren
allChildRows.push(...buildLevel2Group(child, cat.id));
}
}
// Non-inputable intermediate parents
for (const ip of intermediateParents) {
allChildRows.push(...buildLevel2Group(ip, cat.id));
}
if (allChildRows.length === 0) continue;
// Parent subtotal row: sum of leaf rows only (avoid double-counting)
const leafRows = allChildRows.filter((r) => !r.is_parent);
const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0;
let parentPrevYear = 0;
for (const cr of leafRows) {
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual;
parentPrevYear += cr.previousYearTotal;
}
rows.push({
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: null,
is_parent: true,
depth: 0,
months: parentMonths,
annual: parentAnnual,
previousYearTotal: parentPrevYear,
});
// Sort children alphabetically, but keep "(direct)" first
allChildRows.sort((a, b) => {
if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...allChildRows);
}
// else: non-inputable parent with no inputable children — skip
}
// Sort by type, then within each type: keep hierarchy groups together
function getTopGroupId(r: BudgetYearRow): number {
if ((r.depth ?? 0) === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
let pid = r.parent_id;
while (pid !== null) {
const pCat = catById.get(pid);
if (!pCat || !pCat.parent_id) return pid;
pid = pCat.parent_id;
}
return r.category_id;
}
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB;
const groupA = getTopGroupId(a);
const groupB = getTopGroupId(b);
if (groupA !== groupB) {
const catA = catById.get(groupA);
const catB = catById.get(groupB);
const orderA = catA?.sort_order ?? 999;
const orderB = catB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
}
// Same group: sort by depth, then parent before children at same depth
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
if (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.parent_id) return 1;
return a.category_name.localeCompare(b.category_name);
});
dispatch({ type: "SET_DATA", payload: { rows, templates } }); dispatch({ type: "SET_DATA", payload: { rows, templates } });
} catch (e) { } catch (e) {