Compare commits

..

5 commits

Author SHA1 Message Date
ecbcf44f86 Merge pull request 'fix: show actual transactions in budget previous year column (#34)' (#35) from fix/simpl-resultat-34-budget-previous-year-actual into main 2026-03-11 16:16:19 +00:00
21bf1173ea 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 <noreply@anthropic.com>
2026-03-11 12:02:58 -04:00
a764ae0d38 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 <noreply@anthropic.com>
2026-03-11 08:05:34 -04:00
fd88ba41ba Merge branch 'main' into fix/simpl-resultat-34-budget-previous-year-actual 2026-03-11 08:05:24 -04:00
4e70eee0a8 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 <noreply@anthropic.com>
2026-03-10 23:03:26 -04:00
7 changed files with 91 additions and 12 deletions

View file

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

View file

@ -2,6 +2,9 @@
## [Unreleased] ## [Unreleased]
### Changed
- Budget table: previous year column now shows actual transactions instead of planned budget (#34)
## [0.6.5] ## [0.6.5]
### Added ### Added

View file

@ -150,7 +150,7 @@ 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.previousYearTotal * sign; prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
} }
const totalCols = 15; // category + prev year + annual + 12 months const totalCols = 15; // category + prev year + annual + 12 months
@ -197,7 +197,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</div> </div>
</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 * sign)} {formatSigned(row.previousYearTotal)}
</td> </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)}
@ -230,7 +230,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{/* Previous year total — read-only */} {/* Previous year total — read-only */}
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]"> <td className="py-2 px-2 text-right text-[var(--muted-foreground)]">
<span className="text-xs px-1 py-0.5"> <span className="text-xs px-1 py-0.5">
{formatSigned(row.previousYearTotal * sign)} {formatSigned(row.previousYearTotal)}
</span> </span>
</td> </td>
{/* Annual total — editable */} {/* Annual total — editable */}
@ -340,7 +340,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
sectionMonthTotals[m] += row.months[m] * sign; sectionMonthTotals[m] += row.months[m] * sign;
} }
sectionAnnualTotal += row.annual * sign; sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal * sign; sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
} }
return ( return (
<Fragment key={type}> <Fragment key={type}>

View file

@ -0,0 +1,50 @@
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,
* 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("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);
});
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);
});
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 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);
});
});

View file

@ -3,6 +3,7 @@ import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
getActualTotalsForYear,
upsertBudgetEntry, upsertBudgetEntry,
upsertBudgetEntriesForYear, upsertBudgetEntriesForYear,
getAllTemplates, getAllTemplates,
@ -62,6 +63,21 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; const TYPE_ORDER: Record<string, number> = { 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<number, number> {
const prevYearTotalMap = new Map<number, number>();
for (const a of actuals) {
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
}
return prevYearTotalMap;
}
export function useBudget() { export function useBudget() {
const [state, dispatch] = useReducer(reducer, undefined, initialState); const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
@ -72,10 +88,10 @@ export function useBudget() {
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const [allCategories, entries, prevYearEntries, templates] = await Promise.all([ const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getBudgetEntriesForYear(year - 1), getActualTotalsForYear(year - 1),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -88,11 +104,8 @@ export function useBudget() {
entryMap.get(e.category_id)!.set(e.month, e.amount); 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<number, number>(); const prevYearTotalMap = buildPrevYearTotalMap(prevYearActuals);
for (const e of prevYearEntries) {
prevYearTotalMap.set(e.category_id, (prevYearTotalMap.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) => {

View file

@ -178,6 +178,16 @@ export async function deleteTemplate(templateId: number): Promise<void> {
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]); await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
} }
// --- Actuals helpers ---
export async function getActualTotalsForYear(
year: number
): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo);
}
// --- Budget vs Actual --- // --- Budget vs Actual ---
async function getActualsByCategoryRange( async function getActualsByCategoryRange(

View file

@ -142,7 +142,7 @@ export interface BudgetYearRow {
depth?: number; depth?: number;
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
previousYearTotal: number; // total budget from the previous year previousYearTotal: number; // actual (transactions) total from the previous year
} }
export interface ImportConfigTemplate { export interface ImportConfigTemplate {