From 4e70eee0a844be7d876370095baf0cc4ec3c7033 Mon Sep 17 00:00:00 2001 From: medic-bot Date: Tue, 10 Mar 2026 23:03:26 -0400 Subject: [PATCH 1/3] feat: show actual transactions in budget previous year column Replace planned budget data with actual transaction totals for the previous year column in the budget table. Add getActualTotalsForYear helper to budgetService. Ref #34 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.fr.md | 3 +++ CHANGELOG.md | 3 +++ src/hooks/useBudget.ts | 11 ++++++----- src/services/budgetService.ts | 10 ++++++++++ src/shared/types/index.ts | 2 +- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 4fb3f87..202dffd 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,9 @@ ## [Non publié] +### Modifié +- Tableau de budget : la colonne année précédente affiche maintenant le réel (transactions) au lieu du budget planifié (#34) + ## [0.6.5] ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b384a4..81f294b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changed +- Budget table: previous year column now shows actual transactions instead of planned budget (#34) + ## [0.6.5] ### Added diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index 9ee0899..efa99c4 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -3,6 +3,7 @@ import type { BudgetYearRow, BudgetTemplate } from "../shared/types"; import { getAllActiveCategories, getBudgetEntriesForYear, + getActualTotalsForYear, upsertBudgetEntry, upsertBudgetEntriesForYear, getAllTemplates, @@ -72,10 +73,10 @@ export function useBudget() { dispatch({ type: "SET_ERROR", payload: null }); try { - const [allCategories, entries, prevYearEntries, templates] = await Promise.all([ + const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ getAllActiveCategories(), getBudgetEntriesForYear(year), - getBudgetEntriesForYear(year - 1), + getActualTotalsForYear(year - 1), getAllTemplates(), ]); @@ -88,10 +89,10 @@ export function useBudget() { entryMap.get(e.category_id)!.set(e.month, e.amount); } - // Build a map for previous year totals: categoryId -> annual total + // Build a map for previous year actuals: categoryId -> annual actual total const prevYearTotalMap = new Map(); - for (const e of prevYearEntries) { - prevYearTotalMap.set(e.category_id, (prevYearTotalMap.get(e.category_id) ?? 0) + e.amount); + for (const a of prevYearActuals) { + if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual); } // Helper: build months array from entryMap diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 8e4d625..8b2bdb3 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -178,6 +178,16 @@ export async function deleteTemplate(templateId: number): Promise { await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]); } +// --- Actuals helpers --- + +export async function getActualTotalsForYear( + year: number +): Promise> { + const dateFrom = `${year}-01-01`; + const dateTo = `${year}-12-31`; + return getActualsByCategoryRange(dateFrom, dateTo); +} + // --- Budget vs Actual --- async function getActualsByCategoryRange( diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 3c96ae7..93f0019 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -142,7 +142,7 @@ export interface BudgetYearRow { depth?: number; months: number[]; // index 0-11 = Jan-Dec planned amounts annual: number; // computed sum - previousYearTotal: number; // total budget from the previous year + previousYearTotal: number; // actual (transactions) total from the previous year } export interface ImportConfigTemplate { From a764ae0d3832546c9fb2a80d98dc158095c1fabc Mon Sep 17 00:00:00 2001 From: medic-bot Date: Wed, 11 Mar 2026 08:05:34 -0400 Subject: [PATCH 2/3] fix: address reviewer feedback (#34) Fix sign bug in previous year actuals column: transaction amounts are stored with sign in the DB (expenses negative) but budget entries are always positive. Apply Math.abs() when building the previousYearTotal map so the display-time sign multiplier works correctly. Add unit tests for the normalization logic verifying that both expense (negative in DB) and income (positive in DB) amounts are correctly handled. Co-Authored-By: Claude Opus 4.6 --- src/hooks/useBudget.test.ts | 62 +++++++++++++++++++++++++++++++++++++ src/hooks/useBudget.ts | 4 ++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useBudget.test.ts diff --git a/src/hooks/useBudget.test.ts b/src/hooks/useBudget.test.ts new file mode 100644 index 0000000..baa88af --- /dev/null +++ b/src/hooks/useBudget.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; + +/** + * Unit tests for the previous-year actuals normalization logic used in useBudget. + * + * Transaction amounts in the database use signed values (expenses are negative). + * Budget entries are always stored as positive values, with sign applied at display time. + * When building the previousYearTotal map from raw actuals, we must normalize with + * Math.abs() to match the budget convention. Without this, expenses would show + * inverted signs (negative × -1 = positive instead of positive × -1 = negative). + */ + +describe("previous year actuals normalization", () => { + // Simulates the normalization logic from useBudget.ts + function buildPrevYearTotalMap( + actuals: Array<{ category_id: number | null; actual: number }> + ): Map { + const prevYearTotalMap = new Map(); + for (const a of actuals) { + if (a.category_id != null) + prevYearTotalMap.set(a.category_id, Math.abs(a.actual)); + } + return prevYearTotalMap; + } + + it("should store absolute values for expense actuals (negative in DB)", () => { + const actuals = [{ category_id: 1, actual: -500 }]; // expense: negative in DB + const map = buildPrevYearTotalMap(actuals); + expect(map.get(1)).toBe(500); + }); + + it("should store absolute values for income actuals (positive in DB)", () => { + const actuals = [{ category_id: 2, actual: 3000 }]; // income: positive in DB + const map = buildPrevYearTotalMap(actuals); + expect(map.get(2)).toBe(3000); + }); + + it("should skip null category_id entries", () => { + const actuals = [{ category_id: null, actual: -100 }]; + const map = buildPrevYearTotalMap(actuals); + expect(map.size).toBe(0); + }); + + it("should handle zero actuals", () => { + const actuals = [{ category_id: 3, actual: 0 }]; + const map = buildPrevYearTotalMap(actuals); + expect(map.get(3)).toBe(0); + }); + + it("should display correctly with sign multiplier applied", () => { + // Simulate the display logic: previousYearTotal * sign + const signFor = (type: string) => (type === "expense" ? -1 : 1); + + // Expense category: actual was -500 in DB → normalized to 500 → displayed as 500 * -1 = -500 + const expenseActual = Math.abs(-500); + expect(expenseActual * signFor("expense")).toBe(-500); + + // Income category: actual was 3000 in DB → normalized to 3000 → displayed as 3000 * 1 = 3000 + const incomeActual = Math.abs(3000); + expect(incomeActual * signFor("income")).toBe(3000); + }); +}); diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index efa99c4..aa04a99 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -90,9 +90,11 @@ export function useBudget() { } // Build a map for previous year actuals: categoryId -> annual actual total + // Use Math.abs because transaction amounts are stored with sign (expenses negative), + // but budget entries are always positive — the sign is applied at display time. const prevYearTotalMap = new Map(); for (const a of prevYearActuals) { - if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual); + if (a.category_id != null) prevYearTotalMap.set(a.category_id, Math.abs(a.actual)); } // Helper: build months array from entryMap From 21bf1173eaa9fa7decfe9648ee29a9df20beba22 Mon Sep 17 00:00:00 2001 From: medic-bot Date: Wed, 11 Mar 2026 12:02:58 -0400 Subject: [PATCH 3/3] fix: remove double sign negation for previous year actuals (#34) Transaction amounts are already signed in the DB (expenses negative, income positive). Remove Math.abs() normalization and stop multiplying by sign at display time to avoid double negation. Extract buildPrevYearTotalMap as a testable exported function and rewrite tests to import the real function instead of reimplementing it. Co-Authored-By: Claude Opus 4.6 --- src/components/budget/BudgetTable.tsx | 8 ++--- src/hooks/useBudget.test.ts | 52 +++++++++++---------------- src/hooks/useBudget.ts | 22 ++++++++---- 3 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index aa92e4f..b398c95 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -150,7 +150,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu monthTotals[m] += row.months[m] * sign; } annualTotal += row.annual * sign; - prevYearTotal += row.previousYearTotal * sign; + prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB } const totalCols = 15; // category + prev year + annual + 12 months @@ -197,7 +197,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu - {formatSigned(row.previousYearTotal * sign)} + {formatSigned(row.previousYearTotal)} {formatSigned(row.annual * sign)} @@ -230,7 +230,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {/* Previous year total — read-only */} - {formatSigned(row.previousYearTotal * sign)} + {formatSigned(row.previousYearTotal)} {/* Annual total — editable */} @@ -340,7 +340,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu sectionMonthTotals[m] += row.months[m] * sign; } sectionAnnualTotal += row.annual * sign; - sectionPrevYearTotal += row.previousYearTotal * sign; + sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB } return ( diff --git a/src/hooks/useBudget.test.ts b/src/hooks/useBudget.test.ts index baa88af..220b684 100644 --- a/src/hooks/useBudget.test.ts +++ b/src/hooks/useBudget.test.ts @@ -1,35 +1,23 @@ import { describe, it, expect } from "vitest"; +import { buildPrevYearTotalMap } from "./useBudget"; /** * Unit tests for the previous-year actuals normalization logic used in useBudget. * - * Transaction amounts in the database use signed values (expenses are negative). - * Budget entries are always stored as positive values, with sign applied at display time. - * When building the previousYearTotal map from raw actuals, we must normalize with - * Math.abs() to match the budget convention. Without this, expenses would show - * inverted signs (negative × -1 = positive instead of positive × -1 = negative). + * Transaction amounts in the database use signed values (expenses are negative, + * income is positive). The buildPrevYearTotalMap function preserves these signs + * as-is, because the budget display layer does NOT apply a sign multiplier to + * previous year actuals (unlike planned budget amounts). */ -describe("previous year actuals normalization", () => { - // Simulates the normalization logic from useBudget.ts - function buildPrevYearTotalMap( - actuals: Array<{ category_id: number | null; actual: number }> - ): Map { - const prevYearTotalMap = new Map(); - for (const a of actuals) { - if (a.category_id != null) - prevYearTotalMap.set(a.category_id, Math.abs(a.actual)); - } - return prevYearTotalMap; - } - - it("should store absolute values for expense actuals (negative in DB)", () => { +describe("buildPrevYearTotalMap", () => { + it("should preserve negative sign for expense actuals", () => { const actuals = [{ category_id: 1, actual: -500 }]; // expense: negative in DB const map = buildPrevYearTotalMap(actuals); - expect(map.get(1)).toBe(500); + expect(map.get(1)).toBe(-500); }); - it("should store absolute values for income actuals (positive in DB)", () => { + it("should preserve positive sign for income actuals", () => { const actuals = [{ category_id: 2, actual: 3000 }]; // income: positive in DB const map = buildPrevYearTotalMap(actuals); expect(map.get(2)).toBe(3000); @@ -47,16 +35,16 @@ describe("previous year actuals normalization", () => { expect(map.get(3)).toBe(0); }); - it("should display correctly with sign multiplier applied", () => { - // Simulate the display logic: previousYearTotal * sign - const signFor = (type: string) => (type === "expense" ? -1 : 1); - - // Expense category: actual was -500 in DB → normalized to 500 → displayed as 500 * -1 = -500 - const expenseActual = Math.abs(-500); - expect(expenseActual * signFor("expense")).toBe(-500); - - // Income category: actual was 3000 in DB → normalized to 3000 → displayed as 3000 * 1 = 3000 - const incomeActual = Math.abs(3000); - expect(incomeActual * signFor("income")).toBe(3000); + it("should handle multiple categories", () => { + const actuals = [ + { category_id: 1, actual: -200 }, + { category_id: 2, actual: 1500 }, + { category_id: 3, actual: -75.5 }, + ]; + const map = buildPrevYearTotalMap(actuals); + expect(map.size).toBe(3); + expect(map.get(1)).toBe(-200); + expect(map.get(2)).toBe(1500); + expect(map.get(3)).toBe(-75.5); }); }); diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index aa04a99..2f41eab 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -63,6 +63,21 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState { const TYPE_ORDER: Record = { expense: 0, income: 1, transfer: 2 }; +/** + * Build a map of category_id -> annual actual total from raw actuals. + * Transaction amounts are already signed (expenses negative, income positive), + * so they are stored as-is without normalization. + */ +export function buildPrevYearTotalMap( + actuals: Array<{ category_id: number | null; actual: number }> +): Map { + const prevYearTotalMap = new Map(); + for (const a of actuals) { + if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual); + } + return prevYearTotalMap; +} + export function useBudget() { const [state, dispatch] = useReducer(reducer, undefined, initialState); const fetchIdRef = useRef(0); @@ -90,12 +105,7 @@ export function useBudget() { } // Build a map for previous year actuals: categoryId -> annual actual total - // Use Math.abs because transaction amounts are stored with sign (expenses negative), - // but budget entries are always positive — the sign is applied at display time. - const prevYearTotalMap = new Map(); - for (const a of prevYearActuals) { - if (a.category_id != null) prevYearTotalMap.set(a.category_id, Math.abs(a.actual)); - } + const prevYearTotalMap = buildPrevYearTotalMap(prevYearActuals); // Helper: build months array from entryMap const buildMonths = (catId: number) => {