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 <noreply@anthropic.com>
This commit is contained in:
medic-bot 2026-03-08 12:10:25 -04:00
parent c8b92517e8
commit d4625d9f46
7 changed files with 58 additions and 9 deletions

View file

@ -2,6 +2,9 @@
## [Non publié] ## [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] ## [0.6.3]
### Ajouté ### Ajouté

View file

@ -2,6 +2,9 @@
## [Unreleased] ## [Unreleased]
### Added
- Budget: previous year annual total column as baseline reference (#16)
## [0.6.3] ## [0.6.3]
### Added ### Added

View file

@ -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) // Column totals with sign convention (only count leaf rows to avoid double-counting parents)
const monthTotals: number[] = Array(12).fill(0); const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0; let annualTotal = 0;
let prevYearTotal = 0;
for (const row of rows) { for (const row of rows) {
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
const sign = signFor(row.category_type); const sign = signFor(row.category_type);
@ -200,9 +201,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
monthTotals[m] += row.months[m] * sign; monthTotals[m] += row.months[m] * sign;
} }
annualTotal += row.annual * 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) { if (rows.length === 0) {
return ( return (
@ -243,6 +245,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<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> </div>
</td> </td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
{formatSigned(row.prev_year_annual * sign)}
</td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}> <td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(row.annual * sign)} {formatSigned(row.annual * sign)}
</td> </td>
@ -271,6 +276,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className="truncate text-xs">{row.category_name}</span> <span className="truncate text-xs">{row.category_name}</span>
</div> </div>
</td> </td>
{/* Previous year annual — read-only */}
<td className="py-2 px-2 text-right text-xs text-[var(--muted-foreground)]">
{formatSigned(row.prev_year_annual * sign)}
</td>
{/* Annual total — editable */} {/* Annual total — editable */}
<td className="py-2 px-2 text-right"> <td className="py-2 px-2 text-right">
{editingAnnual?.categoryId === row.category_id ? ( {editingAnnual?.categoryId === row.category_id ? (
@ -351,6 +360,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]"> <th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
{t("budget.category")} {t("budget.category")}
</th> </th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.prevYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]"> <th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")} {t("budget.annual")}
</th> </th>
@ -369,11 +381,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
const leaves = group.filter((r) => !r.is_parent); const leaves = group.filter((r) => !r.is_parent);
const sectionMonthTotals: number[] = Array(12).fill(0); const sectionMonthTotals: number[] = Array(12).fill(0);
let sectionAnnualTotal = 0; let sectionAnnualTotal = 0;
let sectionPrevYearTotal = 0;
for (const row of leaves) { for (const row of leaves) {
for (let m = 0; m < 12; m++) { for (let m = 0; m < 12; m++) {
sectionMonthTotals[m] += row.months[m] * sign; sectionMonthTotals[m] += row.months[m] * sign;
} }
sectionAnnualTotal += row.annual * sign; sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.prev_year_annual * sign;
} }
return ( return (
<Fragment key={type}> <Fragment key={type}>
@ -390,6 +404,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<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">{formatSigned(sectionAnnualTotal)}</td> <td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
{sectionMonthTotals.map((total, mIdx) => ( {sectionMonthTotals.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">
@ -403,6 +418,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{/* Totals row */} {/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"> <tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td> <td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td> <td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => ( {monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-3 px-2 text-right text-sm"> <td key={mIdx} className="py-3 px-2 text-right text-sm">

View file

@ -72,9 +72,10 @@ export function useBudget() {
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const [allCategories, entries, templates] = await Promise.all([ const [allCategories, entries, prevYearEntries, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getBudgetEntriesForYear(year - 1),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -87,6 +88,12 @@ export function useBudget() {
entryMap.get(e.category_id)!.set(e.month, e.amount); entryMap.get(e.category_id)!.set(e.month, e.amount);
} }
// Build previous year annual totals: categoryId -> annual sum
const prevYearAnnualMap = new Map<number, number>();
for (const e of prevYearEntries) {
prevYearAnnualMap.set(e.category_id, (prevYearAnnualMap.get(e.category_id) ?? 0) + e.amount);
}
// Helper: build months array from entryMap // Helper: build months array from entryMap
const buildMonths = (catId: number) => { const buildMonths = (catId: number) => {
const monthMap = entryMap.get(catId); const monthMap = entryMap.get(catId);
@ -97,7 +104,8 @@ export function useBudget() {
months.push(val); months.push(val);
annual += 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 // 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); const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) { if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at depth 2 // Leaf at depth 2
const { months, annual } = buildMonths(cat.id); const { months, annual, prev_year_annual } = buildMonths(cat.id);
return [{ return [{
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -128,6 +136,7 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
prev_year_annual,
}]; }];
} }
if (grandchildren.length === 0 && !cat.is_inputable) { if (grandchildren.length === 0 && !cat.is_inputable) {
@ -138,7 +147,7 @@ export function useBudget() {
const gcRows: BudgetYearRow[] = []; const gcRows: BudgetYearRow[] = [];
if (cat.is_inputable) { if (cat.is_inputable) {
const { months, annual } = buildMonths(cat.id); const { months, annual, prev_year_annual } = buildMonths(cat.id);
gcRows.push({ gcRows.push({
category_id: cat.id, category_id: cat.id,
category_name: `${cat.name} (direct)`, category_name: `${cat.name} (direct)`,
@ -149,10 +158,11 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
prev_year_annual,
}); });
} }
for (const gc of grandchildren) { for (const gc of grandchildren) {
const { months, annual } = buildMonths(gc.id); const { months, annual, prev_year_annual } = buildMonths(gc.id);
gcRows.push({ gcRows.push({
category_id: gc.id, category_id: gc.id,
category_name: gc.name, category_name: gc.name,
@ -163,6 +173,7 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
prev_year_annual,
}); });
} }
if (gcRows.length === 0) return []; if (gcRows.length === 0) return [];
@ -170,9 +181,11 @@ export function useBudget() {
// Build intermediate subtotal // Build intermediate subtotal
const subMonths = Array(12).fill(0) as number[]; const subMonths = Array(12).fill(0) as number[];
let subAnnual = 0; let subAnnual = 0;
let subPrevYearAnnual = 0;
for (const cr of gcRows) { for (const cr of gcRows) {
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m]; for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
subAnnual += cr.annual; subAnnual += cr.annual;
subPrevYearAnnual += cr.prev_year_annual;
} }
const subtotal: BudgetYearRow = { const subtotal: BudgetYearRow = {
category_id: cat.id, category_id: cat.id,
@ -184,6 +197,7 @@ export function useBudget() {
depth: 1, depth: 1,
months: subMonths, months: subMonths,
annual: subAnnual, annual: subAnnual,
prev_year_annual: subPrevYearAnnual,
}; };
gcRows.sort((a, b) => { gcRows.sort((a, b) => {
if (a.category_id === cat.id) return -1; 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) { if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf (no children) — regular editable row // Standalone leaf (no children) — regular editable row
const { months, annual } = buildMonths(cat.id); const { months, annual, prev_year_annual } = buildMonths(cat.id);
rows.push({ rows.push({
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -214,13 +228,14 @@ export function useBudget() {
depth: 0, depth: 0,
months, months,
annual, annual,
prev_year_annual,
}); });
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) { } else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetYearRow[] = []; const allChildRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row // If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) { if (cat.is_inputable) {
const { months, annual } = buildMonths(cat.id); const { months, annual, prev_year_annual } = buildMonths(cat.id);
allChildRows.push({ allChildRows.push({
category_id: cat.id, category_id: cat.id,
category_name: `${cat.name} (direct)`, category_name: `${cat.name} (direct)`,
@ -231,6 +246,7 @@ export function useBudget() {
depth: 1, depth: 1,
months, months,
annual, annual,
prev_year_annual,
}); });
} }
@ -238,7 +254,7 @@ export function useBudget() {
const grandchildren = childrenByParent.get(child.id) || []; const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) { if (grandchildren.length === 0) {
// Simple leaf at depth 1 // Simple leaf at depth 1
const { months, annual } = buildMonths(child.id); const { months, annual, prev_year_annual } = buildMonths(child.id);
allChildRows.push({ allChildRows.push({
category_id: child.id, category_id: child.id,
category_name: child.name, category_name: child.name,
@ -249,6 +265,7 @@ export function useBudget() {
depth: 1, depth: 1,
months, months,
annual, annual,
prev_year_annual,
}); });
} else { } else {
// Intermediate parent at depth 1 with grandchildren // Intermediate parent at depth 1 with grandchildren
@ -267,9 +284,11 @@ export function useBudget() {
const leafRows = allChildRows.filter((r) => !r.is_parent); const leafRows = allChildRows.filter((r) => !r.is_parent);
const parentMonths = Array(12).fill(0) as number[]; const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0; let parentAnnual = 0;
let parentPrevYearAnnual = 0;
for (const cr of leafRows) { for (const cr of leafRows) {
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m]; for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual; parentAnnual += cr.annual;
parentPrevYearAnnual += cr.prev_year_annual;
} }
rows.push({ rows.push({
@ -282,6 +301,7 @@ export function useBudget() {
depth: 0, depth: 0,
months: parentMonths, months: parentMonths,
annual: parentAnnual, annual: parentAnnual,
prev_year_annual: parentPrevYearAnnual,
}); });
// Sort children alphabetically, but keep "(direct)" first // Sort children alphabetically, but keep "(direct)" first

View file

@ -318,6 +318,7 @@
"planned": "Planned", "planned": "Planned",
"actual": "Actual", "actual": "Actual",
"difference": "Difference", "difference": "Difference",
"prevYear": "Prev. Year",
"annual": "Annual", "annual": "Annual",
"splitEvenly": "Split evenly across 12 months", "splitEvenly": "Split evenly across 12 months",
"annualMismatch": "Annual total does not match the sum of monthly amounts", "annualMismatch": "Annual total does not match the sum of monthly amounts",
@ -346,6 +347,7 @@
"tips": [ "tips": [
"Use the year navigator to switch between years", "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", "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", "The Annual column shows the total of all 12 months",
"Use the split button to distribute the annual total evenly across all 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" "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.", "overview": "Plan your monthly budget for each category and track planned vs. actual spending throughout the year.",
"features": [ "features": [
"Monthly budget grid for all categories", "Monthly budget grid for all categories",
"Previous year column for reference",
"Annual column with automatic totals", "Annual column with automatic totals",
"Split annual amount evenly across 12 months", "Split annual amount evenly across 12 months",
"Budget templates to save and apply configurations", "Budget templates to save and apply configurations",

View file

@ -318,6 +318,7 @@
"planned": "Prévu", "planned": "Prévu",
"actual": "Réel", "actual": "Réel",
"difference": "Écart", "difference": "Écart",
"prevYear": "An. préc.",
"annual": "Annuel", "annual": "Annuel",
"splitEvenly": "Répartir également sur 12 mois", "splitEvenly": "Répartir également sur 12 mois",
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels", "annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
@ -346,6 +347,7 @@
"tips": [ "tips": [
"Utilisez le navigateur d'année pour changer d'année", "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", "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", "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", "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" "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.", "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": [ "features": [
"Grille budgétaire mensuelle pour toutes les catégories", "Grille budgétaire mensuelle pour toutes les catégories",
"Colonne année précédente pour référence",
"Colonne annuelle avec totaux automatiques", "Colonne annuelle avec totaux automatiques",
"Répartition égale du montant annuel sur 12 mois", "Répartition égale du montant annuel sur 12 mois",
"Modèles de budget pour sauvegarder et appliquer des configurations", "Modèles de budget pour sauvegarder et appliquer des configurations",

View file

@ -142,6 +142,7 @@ export interface BudgetYearRow {
depth?: 0 | 1 | 2; depth?: 0 | 1 | 2;
months: number[]; // index 0-11 = Jan-Dec planned amounts months: number[]; // index 0-11 = Jan-Dec planned amounts
annual: number; // computed sum annual: number; // computed sum
prev_year_annual: number; // previous year total for this category
} }
export interface ImportConfigTemplate { export interface ImportConfigTemplate {