From d4625d9f46d4cb26825c05984fa6d8f7b342ba96 Mon Sep 17 00:00:00 2001 From: medic-bot Date: Sun, 8 Mar 2026 12:10:25 -0400 Subject: [PATCH] Add previous year annual total column to budget table (#16) Fetch previous year budget entries in parallel and display as a read-only reference column between Category and Annual columns. Parent/subtotal rows aggregate children's previous year values. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.fr.md | 3 +++ CHANGELOG.md | 3 +++ src/components/budget/BudgetTable.tsx | 18 +++++++++++++- src/hooks/useBudget.ts | 36 +++++++++++++++++++++------ src/i18n/locales/en.json | 3 +++ src/i18n/locales/fr.json | 3 +++ src/shared/types/index.ts | 1 + 7 files changed, 58 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 60e63aa..f61f2db 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,9 @@ ## [Non publié] +### Ajouté +- Budget : colonne du total annuel de l'année précédente comme base de référence (#16) + ## [0.6.3] ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index 491f0f9..8d86bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- Budget: previous year annual total column as baseline reference (#16) + ## [0.6.3] ### Added diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index 09651b6..aa7e7f7 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -193,6 +193,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu // 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); @@ -200,9 +201,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu monthTotals[m] += row.months[m] * sign; } annualTotal += row.annual * sign; + prevYearTotal += row.prev_year_annual * sign; } - const totalCols = 14; // category + annual + 12 months + const totalCols = 15; // category + prev year + annual + 12 months if (rows.length === 0) { return ( @@ -243,6 +245,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {row.category_name} + + {formatSigned(row.prev_year_annual * sign)} + {formatSigned(row.annual * sign)} @@ -271,6 +276,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {row.category_name} + {/* Previous year annual — read-only */} + + {formatSigned(row.prev_year_annual * sign)} + {/* Annual total — editable */} {editingAnnual?.categoryId === row.category_id ? ( @@ -351,6 +360,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {t("budget.category")} + + {t("budget.prevYear")} + {t("budget.annual")} @@ -369,11 +381,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu 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.prev_year_annual * sign; } return ( @@ -390,6 +404,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {t(typeTotalKeys[type])} + {formatSigned(sectionPrevYearTotal)} {formatSigned(sectionAnnualTotal)} {sectionMonthTotals.map((total, mIdx) => ( @@ -403,6 +418,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {/* Totals row */} {t("common.total")} + {formatSigned(prevYearTotal)} {formatSigned(annualTotal)} {monthTotals.map((total, mIdx) => ( diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index d78ed17..7181504 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -72,9 +72,10 @@ export function useBudget() { dispatch({ type: "SET_ERROR", payload: null }); try { - const [allCategories, entries, templates] = await Promise.all([ + const [allCategories, entries, prevYearEntries, templates] = await Promise.all([ getAllActiveCategories(), getBudgetEntriesForYear(year), + getBudgetEntriesForYear(year - 1), getAllTemplates(), ]); @@ -87,6 +88,12 @@ export function useBudget() { entryMap.get(e.category_id)!.set(e.month, e.amount); } + // Build previous year annual totals: categoryId -> annual sum + const prevYearAnnualMap = new Map(); + for (const e of prevYearEntries) { + prevYearAnnualMap.set(e.category_id, (prevYearAnnualMap.get(e.category_id) ?? 0) + e.amount); + } + // Helper: build months array from entryMap const buildMonths = (catId: number) => { const monthMap = entryMap.get(catId); @@ -97,7 +104,8 @@ export function useBudget() { months.push(val); annual += val; } - return { months, annual }; + const prev_year_annual = prevYearAnnualMap.get(catId) ?? 0; + return { months, annual, prev_year_annual }; }; // Index categories by id and group children by parent_id @@ -117,7 +125,7 @@ export function useBudget() { 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 } = buildMonths(cat.id); + const { months, annual, prev_year_annual } = buildMonths(cat.id); return [{ category_id: cat.id, category_name: cat.name, @@ -128,6 +136,7 @@ export function useBudget() { depth: 2, months, annual, + prev_year_annual, }]; } if (grandchildren.length === 0 && !cat.is_inputable) { @@ -138,7 +147,7 @@ export function useBudget() { const gcRows: BudgetYearRow[] = []; if (cat.is_inputable) { - const { months, annual } = buildMonths(cat.id); + const { months, annual, prev_year_annual } = buildMonths(cat.id); gcRows.push({ category_id: cat.id, category_name: `${cat.name} (direct)`, @@ -149,10 +158,11 @@ export function useBudget() { depth: 2, months, annual, + prev_year_annual, }); } for (const gc of grandchildren) { - const { months, annual } = buildMonths(gc.id); + const { months, annual, prev_year_annual } = buildMonths(gc.id); gcRows.push({ category_id: gc.id, category_name: gc.name, @@ -163,6 +173,7 @@ export function useBudget() { depth: 2, months, annual, + prev_year_annual, }); } if (gcRows.length === 0) return []; @@ -170,9 +181,11 @@ export function useBudget() { // Build intermediate subtotal const subMonths = Array(12).fill(0) as number[]; let subAnnual = 0; + let subPrevYearAnnual = 0; for (const cr of gcRows) { for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m]; subAnnual += cr.annual; + subPrevYearAnnual += cr.prev_year_annual; } const subtotal: BudgetYearRow = { category_id: cat.id, @@ -184,6 +197,7 @@ export function useBudget() { depth: 1, months: subMonths, annual: subAnnual, + prev_year_annual: subPrevYearAnnual, }; gcRows.sort((a, b) => { if (a.category_id === cat.id) return -1; @@ -203,7 +217,7 @@ export function useBudget() { if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) { // Standalone leaf (no children) — regular editable row - const { months, annual } = buildMonths(cat.id); + const { months, annual, prev_year_annual } = buildMonths(cat.id); rows.push({ category_id: cat.id, category_name: cat.name, @@ -214,13 +228,14 @@ export function useBudget() { depth: 0, months, annual, + prev_year_annual, }); } 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 } = buildMonths(cat.id); + const { months, annual, prev_year_annual } = buildMonths(cat.id); allChildRows.push({ category_id: cat.id, category_name: `${cat.name} (direct)`, @@ -231,6 +246,7 @@ export function useBudget() { depth: 1, months, annual, + prev_year_annual, }); } @@ -238,7 +254,7 @@ export function useBudget() { const grandchildren = childrenByParent.get(child.id) || []; if (grandchildren.length === 0) { // Simple leaf at depth 1 - const { months, annual } = buildMonths(child.id); + const { months, annual, prev_year_annual } = buildMonths(child.id); allChildRows.push({ category_id: child.id, category_name: child.name, @@ -249,6 +265,7 @@ export function useBudget() { depth: 1, months, annual, + prev_year_annual, }); } else { // Intermediate parent at depth 1 with grandchildren @@ -267,9 +284,11 @@ export function useBudget() { const leafRows = allChildRows.filter((r) => !r.is_parent); const parentMonths = Array(12).fill(0) as number[]; let parentAnnual = 0; + let parentPrevYearAnnual = 0; for (const cr of leafRows) { for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m]; parentAnnual += cr.annual; + parentPrevYearAnnual += cr.prev_year_annual; } rows.push({ @@ -282,6 +301,7 @@ export function useBudget() { depth: 0, months: parentMonths, annual: parentAnnual, + prev_year_annual: parentPrevYearAnnual, }); // Sort children alphabetically, but keep "(direct)" first diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 73e5a3c..5b8e1c4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -318,6 +318,7 @@ "planned": "Planned", "actual": "Actual", "difference": "Difference", + "prevYear": "Prev. Year", "annual": "Annual", "splitEvenly": "Split evenly across 12 months", "annualMismatch": "Annual total does not match the sum of monthly amounts", @@ -346,6 +347,7 @@ "tips": [ "Use the year navigator to switch between years", "Click on any month cell to edit the planned amount — press Enter to save, Escape to cancel, Tab to move to next month", + "The Prev. Year column shows the previous year's budgeted total as a baseline", "The Annual column shows the total of all 12 months", "Use the split button to distribute the annual total evenly across all months", "Save your budget as a template and apply it to specific months or all 12 at once" @@ -714,6 +716,7 @@ "overview": "Plan your monthly budget for each category and track planned vs. actual spending throughout the year.", "features": [ "Monthly budget grid for all categories", + "Previous year column for reference", "Annual column with automatic totals", "Split annual amount evenly across 12 months", "Budget templates to save and apply configurations", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a5b5f13..710b5a5 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -318,6 +318,7 @@ "planned": "Prévu", "actual": "Réel", "difference": "Écart", + "prevYear": "An. préc.", "annual": "Annuel", "splitEvenly": "Répartir également sur 12 mois", "annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels", @@ -346,6 +347,7 @@ "tips": [ "Utilisez le navigateur d'année pour changer d'année", "Cliquez sur une cellule de mois pour modifier le montant prévu — Entrée pour sauvegarder, Échap pour annuler, Tab pour passer au mois suivant", + "La colonne An. préc. affiche le total budgété de l'année précédente comme base de référence", "La colonne Annuel affiche le total des 12 mois", "Utilisez le bouton de répartition pour distribuer le total annuel également sur tous les mois", "Sauvegardez votre budget comme modèle et appliquez-le à des mois spécifiques ou aux 12 mois d'un coup" @@ -714,6 +716,7 @@ "overview": "Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par rapport au réel tout au long de l'année.", "features": [ "Grille budgétaire mensuelle pour toutes les catégories", + "Colonne année précédente pour référence", "Colonne annuelle avec totaux automatiques", "Répartition égale du montant annuel sur 12 mois", "Modèles de budget pour sauvegarder et appliquer des configurations", diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index bff52b3..20dd71e 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -142,6 +142,7 @@ export interface BudgetYearRow { depth?: 0 | 1 | 2; months: number[]; // index 0-11 = Jan-Dec planned amounts annual: number; // computed sum + prev_year_annual: number; // previous year total for this category } export interface ImportConfigTemplate {