From ab67c68605ef8854f9a51b9ac5f6898fcc075322 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 11 Jul 2026 16:59:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(budget):=20flip=20grid=20to=20income-first?= =?UTF-8?q?=20+=20add=20R=C3=A9sultat=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align BudgetTable on the income-statement standard shipped for the compare/trend reports: sections now read Income -> Expense -> Transfer (useBudget's TYPE_ORDER and BudgetTable's typeOrder both flipped), and the previously unlabeled grand-total row is replaced by two interleaved result rows (Result before transfers / Net result), computed by a new pure, tested module (budgetTableResults.ts) mirroring compareResults.ts's shape. Roll-up covers the previous-year-actual, budgeted-annual, and budgeted-monthly columns alike. All categories remain displayed (the grid stays a full edit surface) -- no collapse, no empty-row toggle, matching this issue's frozen decisions. Resolves #278 --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + src/components/budget/BudgetTable.tsx | 157 ++++++++++-------- .../budget/budgetTableResults.test.ts | 94 +++++++++++ src/components/budget/budgetTableResults.ts | 94 +++++++++++ src/hooks/useBudget.ts | 5 +- 6 files changed, 285 insertions(+), 67 deletions(-) create mode 100644 src/components/budget/budgetTableResults.test.ts create mode 100644 src/components/budget/budgetTableResults.ts diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 16016c8..757e422 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -19,6 +19,7 @@ ### 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). +- Budget : la grille budget se lit désormais comme une analyse de résultat — les catégories sont regroupées **Revenus → Dépenses → Transferts** (auparavant dépenses en premier), et l'ancienne ligne « Total » brute est remplacée par deux lignes de résultat : **Résultat avant transferts** (revenus − dépenses) et **Résultat net** (après transferts), calculées sur les colonnes année précédente, annuelle et mensuelles. Toutes les catégories restent affichées sur la grille — elle demeure une surface d'édition, sans ligne masquée ni repliable (#278). ## [0.12.0] - 2026-07-05 diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d2f68..680115b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### 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). +- Budget: the budget grid now reads as an income statement — categories are grouped **Income → Expenses → Transfers** (previously expenses first), and the old plain "Total" row is replaced by two result lines: **Result before transfers** (income − expenses) and **Net result** (after transfers), computed across the previous-year, annual, and monthly columns alike. Every category still shows on the grid — it stays an editable surface, with no rows hidden or collapsed (#278). ## [0.12.0] - 2026-07-05 diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index b398c95..c902a4c 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { AlertTriangle, ArrowUpDown } from "lucide-react"; import type { BudgetYearRow } from "../../shared/types"; import { reorderRows } from "../../utils/reorderRows"; +import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults"; const fmt = new Intl.NumberFormat("en-CA", { style: "currency", @@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu grouped[key].push(row); } - const typeOrder = ["expense", "income", "transfer"] as const; + // Income-statement reading order: revenue first, then expenses, then + // transfers (Issue #278) — matches the compare/trend reports' ordering. + const typeOrder = ["income", "expense", "transfer"] as const; const typeLabelKeys: Record = { expense: "budget.expenses", income: "budget.income", @@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu transfer: "budget.totalTransfers", }; - // Column totals with sign convention (only count leaf rows to avoid double-counting parents) - const monthTotals: number[] = Array(12).fill(0); - let annualTotal = 0; - let prevYearTotal = 0; - for (const row of rows) { - if (row.is_parent) continue; // skip parent subtotals to avoid double-counting - const sign = signFor(row.category_type); - for (let m = 0; m < 12; m++) { - monthTotals[m] += row.months[m] * sign; - } - annualTotal += row.annual * sign; - prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB - } + // Income-statement roll-up (Issue #278): Résultat avant transferts / net, + // computed on the budgeted + previous-year-actual leaves. Mathematically + // equivalent to the old plain grand-total (income − expense + transfer over + // every leaf), now surfaced as the two labeled result rows below instead of + // a single unlabeled "Total". + const results = computeBudgetResults(rows); const totalCols = 15; // category + prev year + annual + 12 months @@ -295,6 +291,77 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu ); }; + // One type's section: header, its (reorderable) rows, and a leaf-summed + // subtotal row. Extracted so it can be called for income/expense, then + // again for transfer once the Résultat rows are interleaved between them. + const renderTypeSection = (type: (typeof typeOrder)[number]) => { + const group = grouped[type]; + if (!group || group.length === 0) return null; + const sign = signFor(type); + const leaves = group.filter((r) => !r.is_parent); + const sectionMonthTotals: number[] = Array(12).fill(0); + let sectionAnnualTotal = 0; + let sectionPrevYearTotal = 0; + 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 ( + + + + {t(typeLabelKeys[type])} + + + {reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))} + + + {t(typeTotalKeys[type])} + + {formatSigned(sectionPrevYearTotal)} + {formatSigned(sectionAnnualTotal)} + {sectionMonthTotals.map((total, mIdx) => ( + + {formatSigned(total)} + + ))} + + + ); + }; + + // A Résultat row (avant-transferts subtotal, or the net bottom line). + // `strong` mirrors the previous grand-total row's weight (bold, border-t-2); + // the softer variant mirrors the per-type section-subtotal row above. + const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => { + const rowClass = strong + ? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]" + : "bg-[var(--muted)]/40 border-b border-[var(--border)]"; + const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40"; + const cellWeight = strong ? "" : "font-semibold"; + const pad = strong ? "py-3" : "py-2.5"; + return ( + + {t(labelKey)} + + {formatSigned(tot.previousYearTotal)} + + {formatSigned(tot.annual)} + {tot.months.map((val, mIdx) => ( + + {formatSigned(val)} + + ))} + + ); + }; + return (
@@ -327,58 +394,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu - {typeOrder.map((type) => { - const group = grouped[type]; - if (!group || group.length === 0) return null; - const sign = signFor(type); - const leaves = group.filter((r) => !r.is_parent); - const sectionMonthTotals: number[] = Array(12).fill(0); - let sectionAnnualTotal = 0; - let sectionPrevYearTotal = 0; - 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 ( - - - - {t(typeLabelKeys[type])} - - - {reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))} - - - {t(typeTotalKeys[type])} - - {formatSigned(sectionPrevYearTotal)} - {formatSigned(sectionAnnualTotal)} - {sectionMonthTotals.map((total, mIdx) => ( - - {formatSigned(total)} - - ))} - - - ); - })} - {/* Totals row */} - - {t("common.total")} - {formatSigned(prevYearTotal)} - {formatSigned(annualTotal)} - {monthTotals.map((total, mIdx) => ( - - {formatSigned(total)} - - ))} - + {renderTypeSection("income")} + {renderTypeSection("expense")} + {/* Operating result (revenues − expenses), interleaved before the + transfers section only when transfers exist — otherwise it + equals the net result below and would just be noise. */} + {results.hasTransfers && + renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)} + {renderTypeSection("transfer")} + {/* Bottom line: result after netting transfers. */} + {renderResultRow("reports.compare.resultNet", results.resultNet, true)}
diff --git a/src/components/budget/budgetTableResults.test.ts b/src/components/budget/budgetTableResults.test.ts new file mode 100644 index 0000000..2b223b0 --- /dev/null +++ b/src/components/budget/budgetTableResults.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import type { BudgetYearRow } from "../../shared/types"; +import { computeBudgetResults, sumLeavesForType, signForBudgetType } from "./budgetTableResults"; + +// Minimal leaf factory. `months` arrives as the positive-magnitude budgeted +// figure BudgetTable itself flips by type via `signFor` — same convention +// `sumLeavesForType` re-implements. `annual` always equals `sum(months)` +// (useBudget derives it that way; see useBudget.ts's `buildMonths`). +function leaf( + type: BudgetYearRow["category_type"], + months: number[], + previousYearTotal = 0, + overrides: Partial = {}, +): BudgetYearRow { + return { + category_id: 1, + category_name: type, + category_color: "#000", + category_type: type, + parent_id: null, + is_parent: false, + depth: 0, + months, + annual: months.reduce((s, v) => s + v, 0), + previousYearTotal, + ...overrides, + }; +} + +const FLAT12 = (v: number) => Array(12).fill(v) as number[]; + +describe("computeBudgetResults — income-statement roll-up (Issue #278)", () => { + it("resultBefore = budgeted income − expense; resultNet adds transfers", () => { + const r = computeBudgetResults([ + leaf("income", FLAT12(400)), // annual 4800 + leaf("expense", FLAT12(300)), // annual 3600, stored positive, flipped to -300/mo + leaf("transfer", FLAT12(0)), + ]); + expect(r.resultBefore.months[0]).toBe(100); // 400 − 300 + expect(r.resultBefore.annual).toBe(1200); // 4800 − 3600 + expect(r.resultNet.annual).toBe(1200); // balanced (0) transfer → unchanged + expect(r.hasTransfers).toBe(true); + }); + + it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => { + const r = computeBudgetResults([ + leaf("income", FLAT12(1000)), + leaf("expense", FLAT12(700)), + leaf("transfer", FLAT12(50)), // uncategorized single leg, signed pass-through + ]); + expect(r.resultBefore.annual).toBe(3600); // (1000 − 700) × 12 + expect(r.resultNet.annual).toBe(4200); // resultBefore + transfer(50×12) + }); + + it("a deficit yields a negative net result and no transfer line when absent", () => { + const r = computeBudgetResults([leaf("income", FLAT12(200)), leaf("expense", FLAT12(250))]); + expect(r.resultNet.months[0]).toBe(-50); + expect(r.hasTransfers).toBe(false); + }); + + it("ignores subtotal (is_parent) rows so a group is not double-counted", () => { + const parent = leaf("expense", FLAT12(999), 0, { is_parent: true }); + const r = computeBudgetResults([parent, leaf("expense", FLAT12(100))]); + expect(r.expense.annual).toBe(-1200); // only the leaf's 100 × 12 (flipped), not 999 + }); + + it("carries the previous-year actual through unsigned (already signed in the DB)", () => { + // Actual totals arrive pre-signed: expense negative, income positive. + const r = computeBudgetResults([ + leaf("income", FLAT12(100), 1150), + leaf("expense", FLAT12(60), -650), + ]); + expect(r.resultBefore.previousYearTotal).toBe(500); // 1150 + (−650), no re-sign + }); + + it("sumLeavesForType filters by type and excludes parents", () => { + const t = sumLeavesForType( + [ + leaf("expense", FLAT12(50)), + leaf("expense", FLAT12(999), 0, { is_parent: true }), + leaf("income", FLAT12(10)), + ], + "expense", + ); + expect(t.annual).toBe(-600); // 50 × 12, flipped negative + expect(t.months[0]).toBe(-50); + }); + + it("signForBudgetType flips expense only", () => { + expect(signForBudgetType("expense")).toBe(-1); + expect(signForBudgetType("income")).toBe(1); + expect(signForBudgetType("transfer")).toBe(1); + }); +}); diff --git a/src/components/budget/budgetTableResults.ts b/src/components/budget/budgetTableResults.ts new file mode 100644 index 0000000..1e2bfe0 --- /dev/null +++ b/src/components/budget/budgetTableResults.ts @@ -0,0 +1,94 @@ +import type { BudgetYearRow } from "../../shared/types"; + +export type BudgetSectionType = "income" | "expense" | "transfer"; + +/** + * Sign multiplier applied to budgeted (months/annual) magnitudes. Budgeted + * amounts are entered/stored as positive magnitudes regardless of type and + * flipped for display by category type — mirrors `BudgetTable`'s `signFor`. + */ +export function signForBudgetType(type: BudgetSectionType): 1 | -1 { + return type === "expense" ? -1 : 1; +} + +/** + * Aggregate of the budget grid's 3 comparable column groups, already signed: + * 12 monthly (budgeted) totals, the annual (budgeted) total, and the + * previous-year (actual) total. + */ +export interface BudgetTotals { + /** index 0-11 = Jan-Dec, signed budgeted total */ + months: number[]; + /** signed budgeted annual total */ + annual: number; + /** signed actual total from the previous year (already signed in the DB) */ + previousYearTotal: number; +} + +function zeroTotals(): BudgetTotals { + return { months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0 }; +} + +/** + * Sum every non-subtotal (leaf) row of a single type into one `BudgetTotals`. + * + * The type's sign is applied to the budgeted figures (`months`/`annual`), + * matching how `BudgetTable` displays them. `previousYearTotal` is NOT + * re-signed: it comes from `getActualTotalsForYear` (a plain signed + * `SUM(amount)` over the transactions table), already negative for expenses + * and ~0-net for balanced transfers — the same convention `compareResults.ts` + * relies on for its own previous-period figures. + */ +export function sumLeavesForType(rows: BudgetYearRow[], type: BudgetSectionType): BudgetTotals { + const sign = signForBudgetType(type); + const totals = zeroTotals(); + for (const row of rows) { + if (row.is_parent || row.category_type !== type) continue; + for (let m = 0; m < 12; m++) totals.months[m] += row.months[m] * sign; + totals.annual += row.annual * sign; + totals.previousYearTotal += row.previousYearTotal; + } + return totals; +} + +/** Component-wise sum of two BudgetTotals (both already signed). */ +function combine(a: BudgetTotals, b: BudgetTotals): BudgetTotals { + return { + months: a.months.map((v, i) => v + b.months[i]), + annual: a.annual + b.annual, + previousYearTotal: a.previousYearTotal + b.previousYearTotal, + }; +} + +export interface BudgetResults { + income: BudgetTotals; + expense: BudgetTotals; + transfer: BudgetTotals; + /** Budgeted/actual revenues − expenses: the operating result, before transfers. */ + resultBefore: BudgetTotals; + /** resultBefore + transfers: the bottom-line total. */ + resultNet: BudgetTotals; + hasTransfers: boolean; +} + +/** + * Income-statement roll-up for the budget grid (Issue #278), mirroring + * `compareResults.ts`'s shape (see that module's doc comment for the sign + * convention this relies on). + * + * Operates on the flat `BudgetYearRow[]` the grid already renders (produced + * by `useBudget`) — LEAVES only (`is_parent === false`), so parent subtotal + * rows are never double-counted. All three column groups (previous-year + * actual, budgeted annual, budgeted monthly) are folded into the same result + * rows so "Résultat avant transferts" / "Résultat net" read consistently + * across every column of the grid. + */ +export function computeBudgetResults(rows: BudgetYearRow[]): BudgetResults { + const income = sumLeavesForType(rows, "income"); + const expense = sumLeavesForType(rows, "expense"); + const transfer = sumLeavesForType(rows, "transfer"); + const resultBefore = combine(income, expense); + const resultNet = combine(resultBefore, transfer); + const hasTransfers = rows.some((r) => !r.is_parent && r.category_type === "transfer"); + return { income, expense, transfer, resultBefore, resultNet, hasTransfers }; +} diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index a8ffb8f..0062612 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -68,7 +68,10 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState { } } -const TYPE_ORDER: Record = { expense: 0, income: 1, transfer: 2 }; +// Income-statement reading order: revenue first, then expenses, then +// transfers (Issue #278) — matches the compare/trend reports' ordering +// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253). +const TYPE_ORDER: Record = { income: 0, expense: 1, transfer: 2 }; export function useBudget() { const { accountIds } = useReportsPeriod();