Compare commits

...

7 commits

Author SHA1 Message Date
le king fu
a982f9ed9d Merge issue-279 (dashboard Cartes convergence) into main
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-11 21:22:29 -04:00
le king fu
6dbaaa25fe Merge issue-277 (BVA income-first) into main
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-11 21:21:20 -04:00
le king fu
e55c3bd250 Merge issue-278 (budget grid income-first) into main 2026-07-11 21:20:07 -04:00
le king fu
9ee5ad353f fix(reports): thread accountIds into Cartes KPI + seasonality series
All checks were successful
PR Check / rust (pull_request) Successful in 21m29s
PR Check / frontend (pull_request) Successful in 2m24s
getCartesSnapshot forwarded the account filter only to the top-movers and
budget sub-reports, leaving fetchMonthlyFlows (KPIs, sparklines, 12-month
overlay) and fetchSeasonality unfiltered. The Dashboard is the first page to
expose the account filter over these series, so its KPI cards showed
unfiltered totals while the category bars / trend / top-movers respected the
filter — figures that did not reconcile on the same screen.

Thread an optional accountIds through both fetchers (parameterized
`source_id IN (...)` via inPlaceholders) and pass it from getCartesSnapshot,
so the whole dashboard honours the filter — which is what the #279 CHANGELOG
entry already promises. No CHANGELOG change: the code now matches the note.

Resolves #279

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:16:09 -04:00
le king fu
193ef1d9dd feat(dashboard): converge home page on the Cartes report model
All checks were successful
PR Check / rust (pull_request) Successful in 20m42s
PR Check / frontend (pull_request) Successful in 2m22s
DashboardPage now reuses the Cartes report's presentational widgets
(KpiCard with MoM/YoY deltas, top movers, budget adherence) sourced from
getCartesSnapshot against a Dashboard-owned reference month (defaults to
the last complete month). The expense-only pie chart is replaced by a
ranked bar chart of top expense categories (resurrecting the previously
unused CategoryBarChart), and the account (import-source) filter
introduced on Trends/Compare/Budget now also applies to the Dashboard's
own transactional widgets via a Dashboard-local accountIds state (kept
separate from useReportsPeriod, since the Dashboard owns its own two
temporal axes).

A new net-worth tile surfaces the Balance sheet's latest total
(getSnapshotTotalsByDate) — a distinct metric from every transactional
card here, so it stays hidden (never a misleading "$0") until at least
one balance account has a recorded snapshot, reusing deriveLandingState
rather than re-inferring emptiness from nulls. It is not scoped by the
account filter (balance_accounts is a disjoint concept from
import_sources) and is fetched independently on mount.

The category-over-time trend chart now passes typeFilter "expense",
fixing a latent mismatch where a revenue category could silently show
up in a chart titled "expenses over time".

Resolves #279
2026-07-11 17:13:23 -04:00
le king fu
14755eb155 feat(reports): BVA (actual vs budget) income-first + result lines
All checks were successful
PR Check / rust (pull_request) Successful in 20m38s
PR Check / frontend (pull_request) Successful in 2m20s
Align BudgetVsActualTable on the income-statement gold standard already
shipped for the real-vs-real compare report (#253/#256): flip TYPE_ORDER
to income-first (revenue -> expense -> transfer) and replace the flat
"Total" row with interleaved Result before transfers / Net result lines.

Roll-up logic extracted into a new pure, tested module
(budgetVsActualResults.ts) mirroring compareResults.ts, with one sign
difference: BVA amounts already arrive signed the accounting way
(expense actual/budget are negative), so the operating result is a
straight add of income + expense rather than a subtraction.

Resolves #277

Generated autonomously by /autopilot run of 2026-07-11
2026-07-11 16:59:16 -04:00
le king fu
ab67c68605 feat(budget): flip grid to income-first + add Résultat rows
All checks were successful
PR Check / rust (pull_request) Successful in 21m4s
PR Check / frontend (pull_request) Successful in 2m20s
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
2026-07-11 16:59:01 -04:00
20 changed files with 1162 additions and 397 deletions

View file

@ -19,6 +19,9 @@
### Modifié ### 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). - 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).
- Rapports → Comparaison (réel vs budget) : le tableau budget-vs-réel se lit désormais comme les autres rapports comparables — une **analyse de résultat**. Les catégories sont regroupées Revenus → Dépenses → Transferts (au lieu de Dépenses → Revenus → Transferts), et l'ancienne ligne « Total » plate est remplacée par une ligne **Résultat avant transferts** (affichée lorsque des transferts existent) et une ligne **Résultat net**, toutes deux colorées en vert pour un surplus, en rouge pour un déficit. Les montants de catégorie et de section sont inchangés (#277).
- 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).
- Tableau de bord (`/`) : la page d'accueil adopte désormais le modèle du rapport Cartes. Les cartes d'indicateurs (revenus, dépenses, résultat net, taux d'épargne) affichent leur variation vs le mois précédent et vs l'an dernier (sélecteur de mois de référence, par défaut le dernier mois complet) ; les catégories en hausse/en baisse et le respect du budget sont présentés de la même façon que sur Cartes. Une nouvelle tuile **valeur nette** affiche le dernier total du Bilan — une métrique différente de toutes les autres cartes de la page, donc masquée (jamais un « 0 $ » trompeur) tant qu'aucun compte de bilan n'a de snapshot enregistré, et jamais affectée par le filtre de comptes ci-dessous. Le camembert des dépenses est remplacé par des **barres classées** des catégories de dépense les plus importantes, et le filtre de comptes (sources d'import) — introduit sur Tendances/Comparaison/Budget — s'applique désormais aussi aux widgets transactionnels du tableau de bord (#279).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05

View file

@ -19,6 +19,9 @@
### Changed ### 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). - 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).
- Reports → Compare (actual vs budget): the budget-vs-actual table now reads the same way as the other comparable reports — an **income statement**. Categories are grouped Income → Expenses → Transfers (instead of Expenses → Income → Transfers), and the old flat "Total" row is replaced by a **Result before transfers** line (shown when transfers exist) and a **Net result** line, both coloured green for a surplus and red for a deficit. Leaf and section figures are unchanged (#277).
- 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).
- Dashboard (`/`): the home page now matches the Cartes report's model. KPI cards for income, expenses, net result and savings rate show their change vs the previous month and vs last year (reference-month picker, defaulting to the last complete month); rising/falling categories and budget adherence are shown the same way as on Cartes. A new **net worth** tile shows the Balance sheet's latest total — a different metric from every other card here, so it stays hidden (never a misleading "$0") until at least one balance account has a recorded snapshot, and it is never affected by the account filter below. The expense-only pie chart is replaced by a **ranked bar chart** of top expense categories, and the account (import-source) filter — introduced on Trends/Compare/Budget — now also applies to the Dashboard's own transactional widgets (#279).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05

View file

@ -172,7 +172,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) | | `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
| `adjustmentService.ts` | Gestion des ajustements | | `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire | | `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord | | `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) | | `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré) | | `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur | | `userPreferenceService.ts` | Stockage préférences utilisateur |
@ -205,8 +205,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useImportHistory` | Historique des imports | | `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord | | `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276) — reste Dashboard/Hub (M2) | | `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
| `useHighlights` | Panneau de faits saillants du hub rapports | | `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | | `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) | | `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
@ -350,7 +350,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| Route | Page | Description | | Route | Page | Description |
|-------|------|-------------| |-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) | | `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
| `/import` | `ImportPage` | Assistant d'import CSV | | `/import` | `ImportPage` | Assistant d'import CSV |
| `/transactions` | `TransactionsPage` | Liste avec filtres | | `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique | | `/categories` | `CategoriesPage` | Gestion hiérarchique |

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react"; import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
grouped[key].push(row); 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<string, string> = { const typeLabelKeys: Record<string, string> = {
expense: "budget.expenses", expense: "budget.expenses",
income: "budget.income", income: "budget.income",
@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
transfer: "budget.totalTransfers", transfer: "budget.totalTransfers",
}; };
// Column totals with sign convention (only count leaf rows to avoid double-counting parents) // Income-statement roll-up (Issue #278): Résultat avant transferts / net,
const monthTotals: number[] = Array(12).fill(0); // computed on the budgeted + previous-year-actual leaves. Mathematically
let annualTotal = 0; // equivalent to the old plain grand-total (income expense + transfer over
let prevYearTotal = 0; // every leaf), now surfaced as the two labeled result rows below instead of
for (const row of rows) { // a single unlabeled "Total".
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting const results = computeBudgetResults(rows);
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
}
const totalCols = 15; // category + prev year + annual + 12 months const totalCols = 15; // category + prev year + annual + 12 months
@ -295,39 +291,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
); );
}; };
return ( // One type's section: header, its (reorderable) rows, and a leaf-summed
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"> // subtotal row. Extracted so it can be called for income/expense, then
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> // again for transfer once the Résultat rows are interleaved between them.
<button const renderTypeSection = (type: (typeof typeOrder)[number]) => {
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<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")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")}
</th>
{MONTH_KEYS.map((key) => (
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
{t(key)}
</th>
))}
</tr>
</thead>
<tbody>
{typeOrder.map((type) => {
const group = grouped[type]; const group = grouped[type];
if (!group || group.length === 0) return null; if (!group || group.length === 0) return null;
const sign = signFor(type); const sign = signFor(type);
@ -367,18 +334,76 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</tr> </tr>
</Fragment> </Fragment>
); );
})} };
{/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"> // A Résultat row (avant-transferts subtotal, or the net bottom line).
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td> // `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td> // the softer variant mirrors the per-type section-subtotal row above.
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td> const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
{monthTotals.map((total, mIdx) => ( const rowClass = strong
<td key={mIdx} className="py-3 px-2 text-right text-sm"> ? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
{formatSigned(total)} : "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 (
<tr key={labelKey} className={rowClass}>
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
{formatSigned(tot.previousYearTotal)}
</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
{tot.months.map((val, mIdx) => (
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
{formatSigned(val)}
</td> </td>
))} ))}
</tr> </tr>
);
};
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<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")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")}
</th>
{MONTH_KEYS.map((key) => (
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
{t(key)}
</th>
))}
</tr>
</thead>
<tbody>
{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)}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -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> = {},
): 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);
});
});

View file

@ -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 };
}

View file

@ -0,0 +1,62 @@
// NetWorthTile — Dashboard KPI-row tile showing the Bilan's latest net worth.
//
// Issue #279. Deliberately NOT `HubNetBalanceTile` (that one is the reports
// hub's transaction-P&L net balance — a different metric). This tile mirrors
// `BalanceOverviewCard`'s "latest total" figure and reuses its exact i18n
// copy (`balance.overview.latestTotal` / `asOf`) so the label is unambiguous
// next to the transactional KPI cards and the "import sources" account
// filter — same words as the Bilan page itself, never "balance"/"solde".
//
// Renders nothing when `data.visible` is false (no balance account yet, or
// accounts but no snapshot yet) — never a misleading "$0" tile.
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Wallet } from "lucide-react";
import type { NetWorthTileData } from "../../services/dashboardService";
export interface NetWorthTileProps {
data: NetWorthTileData;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function NetWorthTile({ data }: NetWorthTileProps) {
const { t, i18n } = useTranslation();
if (!data.visible || data.total === null || data.asOfDate === null) {
return null;
}
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formattedDate = new Date(data.asOfDate).toLocaleDateString(dateLocale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<Link
to="/balance"
data-kpi="net-worth"
className="flex-1 min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3 hover:border-[var(--primary)] transition-colors"
>
<div className="text-sm text-[var(--muted-foreground)] flex items-center gap-1.5">
<Wallet size={14} aria-hidden="true" />
<span>{t("balance.overview.latestTotal")}</span>
</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{formatCurrency(data.total, i18n.language)}
</div>
<div className="text-xs text-[var(--muted-foreground)] pt-1 border-t border-[var(--border)]">
{t("balance.overview.asOf", { date: formattedDate })}
</div>
</Link>
);
}

View file

@ -5,6 +5,7 @@ import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows"; import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups"; import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { new Intl.NumberFormat("en-CA", {
@ -68,8 +69,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
); );
} }
// Group rows by type for section headers // Group rows into contiguous type sections (the service already type-sorts:
type SectionType = "expense" | "income" | "transfer"; // income → expense → transfer, Issue #277).
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = []; const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
const typeLabels: Record<SectionType, string> = { const typeLabels: Record<SectionType, string> = {
expense: t("budget.expenses"), expense: t("budget.expenses"),
@ -91,103 +92,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
sections[sections.length - 1].rows.push(row); sections[sections.length - 1].rows.push(row);
} }
// Grand totals (leaf rows only) // Income-statement result lines (Issue #277): income + expense (expense
const leaves = data.filter((r) => !r.is_parent); // already arrives signed negative in BudgetVsActualRow — see
const totals = leaves.reduce( // budgetVsActualResults.ts), then the net after adding transfers. Replaces
(acc, r) => ({ // the old flat grand total row.
monthActual: acc.monthActual + r.monthActual, const results = computeResults(data);
monthBudget: acc.monthBudget + r.monthBudget, const nonTransferSections = sections.filter((s) => s.type !== "transfer");
monthVariation: acc.monthVariation + r.monthVariation, const transferSection = sections.find((s) => s.type === "transfer");
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
const hasGroups = groups.groupCount(data) > 0; const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
const allExpanded = groups.allExpanded(data); const sectionTotals = sumLeaves(section.rows);
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
return ( const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget);
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
{t("budget.category")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("reports.bva.monthly")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("reports.bva.ytd")}
</th>
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{sections.map((section) => {
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
const sectionTotals = sectionLeaves.reduce(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
return ( return (
<Fragment key={section.type}> <Fragment key={section.type}>
<tr className="bg-[var(--muted)]"> <tr className="bg-[var(--muted)]">
@ -293,31 +209,118 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr> </tr>
</Fragment> </Fragment>
); );
})} };
{/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"> // A result line (before-transfers subtotal or the net total). Every cell is
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td> // coloured by its own sign — a surplus is green, a deficit red — mirroring
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50"> // ComparePeriodTable.renderResultRow (unlike normal rows, which only colour
{cadFormatter(totals.monthActual)} // the variation/pct columns).
const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => {
const rowBg = strong
? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"
: "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]";
const rowClass = strong
? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}`
: `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`;
const cell = "text-right px-3 py-3";
const monthPct = pct(tot.monthVariation, tot.monthBudget);
const ytdPct = pct(tot.ytdVariation, tot.ytdBudget);
return (
<tr key={labelKey} className={rowClass}>
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.monthActual)}`}>
{cadFormatter(tot.monthActual)}
</td> </td>
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td> <td className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}> <td className={`${cell} ${variationColor(tot.monthVariation)}`}>
{cadFormatter(totals.monthVariation)} {cadFormatter(tot.monthVariation)}
</td> </td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}> <td className={`${cell} ${variationColor(tot.monthVariation)}`}>{pctFormatter(monthPct)}</td>
{pctFormatter(totalMonthPct)} <td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.ytdActual)}`}>
</td> {cadFormatter(tot.ytdActual)}
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.ytdActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)}
</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{pctFormatter(totalYtdPct)}
</td> </td>
<td className={`${cell} ${variationColor(tot.ytdBudget)}`}>{cadFormatter(tot.ytdBudget)}</td>
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{cadFormatter(tot.ytdVariation)}</td>
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{pctFormatter(ytdPct)}</td>
</tr> </tr>
);
};
const hasGroups = groups.groupCount(data) > 0;
const allExpanded = groups.allExpanded(data);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
{t("budget.category")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("reports.bva.monthly")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("reports.bva.ytd")}
</th>
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{nonTransferSections.map(renderSection)}
{/* Operating result (income + expense), shown before the transfers
section only when transfers exist otherwise it equals the net
total below and would just be noise (mirrors ComparePeriodTable). */}
{results.hasTransfers &&
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
{transferSection && renderSection(transferSection)}
{/* Bottom line: result after adding transfers. */}
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import type { BudgetVsActualRow } from "../../shared/types";
import { computeResults, sumLeaves } from "./budgetVsActualResults";
// Minimal leaf factory. Amounts arrive from getBudgetVsActualData already in
// the per-type sign convention: expense actual/budget are negative (raw,
// unsigned SUM(transactions.amount) — expenses are stored negative — and the
// user-entered budget negated to match via signFor); income/transfer stay
// however they naturally sum (positive for income, signed net for transfer).
function leaf(
type: "expense" | "income" | "transfer",
monthActual: number,
monthBudget: number,
ytdActual = monthActual,
ytdBudget = monthBudget,
): BudgetVsActualRow {
return {
category_id: 1,
category_name: type,
category_color: "#000",
category_type: type,
parent_id: null,
is_parent: false,
monthActual,
monthBudget,
monthVariation: monthActual - monthBudget,
monthVariationPct: monthBudget !== 0 ? (monthActual - monthBudget) / Math.abs(monthBudget) : null,
ytdActual,
ytdBudget,
ytdVariation: ytdActual - ytdBudget,
ytdVariationPct: ytdBudget !== 0 ? (ytdActual - ytdBudget) / Math.abs(ytdBudget) : null,
};
}
describe("computeResults — income-statement roll-up (Issue #277)", () => {
it("resultBefore = income + expense (expense already signed negative)", () => {
const r = computeResults([
leaf("income", 3000, 2800),
leaf("expense", -1800, -2000), // spent 1800, budgeted to spend 2000
leaf("transfer", 0, 0),
]);
// Operating result: 3000 + (-1800) = 1200 actual; 2800 + (-2000) = 800 budgeted.
expect(r.resultBefore.monthActual).toBe(1200);
expect(r.resultBefore.monthBudget).toBe(800);
expect(r.resultBefore.monthVariation).toBe(400);
// Balanced transfer (0) → net equals the operating result.
expect(r.resultNet.monthActual).toBe(1200);
expect(r.hasTransfers).toBe(true);
});
it("a non-zero transfer moves resultNet away from resultBefore", () => {
const r = computeResults([
leaf("income", 4000, 4000),
leaf("expense", -3000, -3000),
leaf("transfer", -200, 0), // a single categorised leg
]);
expect(r.resultBefore.monthActual).toBe(1000);
// Net folds the residual transfer in: 1000 + (200) = 800.
expect(r.resultNet.monthActual).toBe(800);
});
it("a deficit yields a negative net result and no transfer line", () => {
const r = computeResults([leaf("income", 2000, 2000), leaf("expense", -2500, -2400)]);
expect(r.resultNet.monthActual).toBe(-500);
expect(r.hasTransfers).toBe(false);
});
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
const parent = { ...leaf("expense", -999, -999), is_parent: true };
const r = computeResults([parent, leaf("expense", -100, -80)]);
expect(r.expense.monthActual).toBe(-100);
});
it("carries the YTD figures through the same arithmetic", () => {
const r = computeResults([
leaf("income", 100, 100, 1200, 1150),
leaf("expense", -60, -60, -700, -650),
]);
expect(r.resultBefore.ytdActual).toBe(500);
expect(r.resultBefore.ytdBudget).toBe(500);
});
it("sumLeaves adds only leaves", () => {
const t = sumLeaves([
leaf("expense", -100, -50),
{ ...leaf("expense", -999, -999), is_parent: true },
]);
expect(t.monthActual).toBe(-100);
expect(t.monthBudget).toBe(-50);
});
});

View file

@ -0,0 +1,95 @@
import type { BudgetVsActualRow } from "../../shared/types";
export type SectionType = "expense" | "income" | "transfer";
/** Aggregate of the 6 budget-vs-actual figures across a set of leaf rows. */
export interface Totals {
monthActual: number;
monthBudget: number;
monthVariation: number;
ytdActual: number;
ytdBudget: number;
ytdVariation: number;
}
const ZERO: Totals = {
monthActual: 0,
monthBudget: 0,
monthVariation: 0,
ytdActual: 0,
ytdBudget: 0,
ytdVariation: 0,
};
/** Sum every non-subtotal (leaf) row into a single Totals. */
export function sumLeaves(rows: BudgetVsActualRow[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ ...ZERO },
);
}
/** Variation as a fraction of budget (null when budget is 0 mirrors the
* row-level `*VariationPct` fields computed in `getBudgetVsActualData`). */
export function pct(variation: number, budget: number): number | null {
return budget !== 0 ? variation / Math.abs(budget) : null;
}
/** Component-wise a + b across the 6 figures. */
function add(a: Totals, b: Totals): Totals {
return {
monthActual: a.monthActual + b.monthActual,
monthBudget: a.monthBudget + b.monthBudget,
monthVariation: a.monthVariation + b.monthVariation,
ytdActual: a.ytdActual + b.ytdActual,
ytdBudget: a.ytdBudget + b.ytdBudget,
ytdVariation: a.ytdVariation + b.ytdVariation,
};
}
export interface BudgetVsActualResults {
income: Totals;
expense: Totals;
transfer: Totals;
/** Income + expense: the operating result, before transfers. */
resultBefore: Totals;
/** resultBefore + transfers: the bottom-line total. */
resultNet: Totals;
hasTransfers: boolean;
}
/**
* Income-statement roll-up for the budget-vs-actual report (Issue #277).
*
* Mirrors `compareResults.computeResults`, with one key sign difference: BVA
* amounts already arrive signed the "accounting" way rather than ABS'd to
* positive magnitudes. `getBudgetVsActualData` computes `monthActual`/
* `ytdActual` as the raw (unsigned-by-us) `SUM(transactions.amount)` negative
* for expense categories, since expense transactions are stored as negative
* amounts throughout this app (see reportService/dashboardService `amount < 0`
* conventions) and negates the user-entered (positive) budget figure to
* match via `signFor`. So expense totals here are already 0, and the
* operating result is a straight ADD of income + expense (not a subtraction
* like the real-vs-real compare, whose expense figures are ABS'd positive
* magnitudes). The net just adds the transfer total on top.
*/
export function computeResults(rows: BudgetVsActualRow[]): BudgetVsActualResults {
const byType = (type: SectionType): Totals =>
sumLeaves(rows.filter((r) => r.category_type === type));
const income = byType("income");
const expense = byType("expense");
const transfer = byType("transfer");
const resultBefore = add(income, expense);
const resultNet = add(resultBefore, transfer);
const hasTransfers = rows.some((r) => r.category_type === "transfer" && !r.is_parent);
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
}

View file

@ -68,7 +68,10 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
} }
} }
const TYPE_ORDER: Record<string, number> = { 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<string, number> = { income: 0, expense: 1, transfer: 2 };
export function useBudget() { export function useBudget() {
const { accountIds } = useReportsPeriod(); const { accountIds } = useReportsPeriod();

View file

@ -1,29 +1,63 @@
// useDashboard — scoped useReducer hook backing DashboardPage (the `/` home page).
//
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
// row, top movers and budget adherence are the exact Cartes widgets, sourced
// from `getCartesSnapshot` against a (year, month) reference period. The
// Dashboard keeps its OWN two temporal axes, neither of which is
// `useReportsPeriod` (M1/I4 deliberately left the Dashboard untouched):
// - `period` / `customDateFrom` / `customDateTo` — a flexible date range,
// feeding the two range-based transactional widgets (top-expenses bar
// chart, category-over-time trend).
// - `referenceYear` / `referenceMonth` — a single reference month, feeding
// the Cartes snapshot (KPIs + deltas, top movers, budget adherence, the
// income-vs-expenses overlay chart). Defaults to the previous complete
// month, like Cartes/Compare (`defaultReferencePeriod`).
//
// The account (import-source) filter (`accountIds`) is Dashboard-local state
// (not routed through `useReportsPeriod`), but applies to every transactional
// widget above — both temporal axes. It does NOT scope the net-worth tile
// (see below): `balance_accounts` is a disjoint concept from `import_sources`.
//
// The net-worth tile is fetched independently, once, on mount — it is not a
// transactional figure, has no relationship to `period`/`referenceMonth`, and
// must not be scoped by `accountIds`. Keeping it in its own effect makes that
// decoupling explicit in the code, not just in the UI copy.
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { import type {
DashboardPeriod, DashboardPeriod,
DashboardSummary,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
BudgetVsActualRow, CartesSnapshot,
ImportSource,
} from "../shared/types"; } from "../shared/types";
import { import {
getDashboardSummary,
getExpensesByCategory, getExpensesByCategory,
deriveNetWorthTile,
type NetWorthTileData,
} from "../services/dashboardService"; } from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService"; import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService"; import { getAllImportSources } from "../services/transactionService";
import {
getSnapshotTotalsByDate,
listSnapshots,
listBalanceAccounts,
} from "../services/balance.service";
import { computeDateRange } from "../utils/dateRange"; import { computeDateRange } from "../utils/dateRange";
import { defaultReferencePeriod } from "../utils/referencePeriod";
interface DashboardState { interface DashboardState {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[]; cartesSnapshot: CartesSnapshot | null;
period: DashboardPeriod; period: DashboardPeriod;
budgetYear: number;
budgetMonth: number;
customDateFrom: string; customDateFrom: string;
customDateTo: string; customDateTo: string;
referenceYear: number;
referenceMonth: number;
accountIds: number[];
accounts: ImportSource[];
netWorth: NetWorthTileData;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -34,30 +68,46 @@ type DashboardAction =
| { | {
type: "SET_DATA"; type: "SET_DATA";
payload: { payload: {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[]; cartesSnapshot: CartesSnapshot;
}; };
} }
| { type: "SET_PERIOD"; payload: DashboardPeriod } | { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } } | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }; | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_ACCOUNT_IDS"; payload: number[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_NET_WORTH"; payload: NetWorthTileData };
const now = new Date(); const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const yearStartStr = `${now.getFullYear()}-01-01`; const yearStartStr = `${now.getFullYear()}-01-01`;
const defaultRef = defaultReferencePeriod();
const EMPTY_CATEGORY_OVER_TIME: CategoryOverTimeData = {
categories: [],
data: [],
colors: {},
categoryIds: {},
types: {},
tree: [],
};
const EMPTY_NET_WORTH: NetWorthTileData = { visible: false, total: null, asOfDate: null };
const initialState: DashboardState = { const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [], categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] }, categoryOverTime: EMPTY_CATEGORY_OVER_TIME,
budgetVsActual: [], cartesSnapshot: null,
period: "year", period: "year",
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
customDateFrom: yearStartStr, customDateFrom: yearStartStr,
customDateTo: todayStr, customDateTo: todayStr,
referenceYear: defaultRef.year,
referenceMonth: defaultRef.month,
accountIds: [],
accounts: [],
netWorth: EMPTY_NET_WORTH,
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -71,18 +121,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
case "SET_DATA": case "SET_DATA":
return { return {
...state, ...state,
summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown, categoryBreakdown: action.payload.categoryBreakdown,
categoryOverTime: action.payload.categoryOverTime, categoryOverTime: action.payload.categoryOverTime,
budgetVsActual: action.payload.budgetVsActual, cartesSnapshot: action.payload.cartesSnapshot,
isLoading: false, isLoading: false,
}; };
case "SET_PERIOD": case "SET_PERIOD":
return { ...state, period: action.payload }; return { ...state, period: action.payload };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_CUSTOM_DATES": case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo }; return {
...state,
period: "custom" as DashboardPeriod,
customDateFrom: action.payload.dateFrom,
customDateTo: action.payload.dateTo,
};
case "SET_REFERENCE_PERIOD":
return { ...state, referenceYear: action.payload.year, referenceMonth: action.payload.month };
case "SET_ACCOUNT_IDS":
return { ...state, accountIds: action.payload };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_NET_WORTH":
return { ...state, netWorth: action.payload };
default: default:
return state; return state;
} }
@ -96,8 +156,9 @@ export function useDashboard() {
period: DashboardPeriod, period: DashboardPeriod,
customFrom: string | undefined, customFrom: string | undefined,
customTo: string | undefined, customTo: string | undefined,
bYear: number, refYear: number,
bMonth: number, refMonth: number,
accountIds: number[],
) => { ) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
@ -105,15 +166,17 @@ export function useDashboard() {
try { try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([ const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([
getDashboardSummary(dateFrom, dateTo), getExpensesByCategory(dateFrom, dateTo, accountIds),
getExpensesByCategory(dateFrom, dateTo), // typeFilter "expense" (Issue #279): this chart is titled "expenses
getCategoryOverTime(dateFrom, dateTo), // over time" but previously aggregated every category type — a
getBudgetVsActualData(bYear, bMonth), // revenue category could silently show up in an "expenses" trend.
getCategoryOverTime(dateFrom, dateTo, undefined, accountIds, "expense"),
getCartesSnapshot(refYear, refMonth, "month", accountIds),
]); ]);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } }); dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } });
} catch (e) { } catch (e) {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ dispatch({
@ -124,8 +187,59 @@ export function useDashboard() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth); fetchData(
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]); state.period,
state.customDateFrom,
state.customDateTo,
state.referenceYear,
state.referenceMonth,
state.accountIds,
);
}, [
state.period,
state.customDateFrom,
state.customDateTo,
state.referenceYear,
state.referenceMonth,
state.accountIds,
fetchData,
]);
// One-time: import sources for the FilterPanel's account checkboxes (same
// query as Trends/Compare/Budget — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
// One-time: net-worth tile (Bilan). Independent of `period`/`referenceMonth`
// and NOT scoped by `accountIds` — a different "account" concept
// (balance_accounts vs import_sources) and a different metric (net worth vs
// transaction P&L). A failure here only hides the tile (best-effort); it
// never surfaces as a page-wide error for what is a secondary widget.
useEffect(() => {
(async () => {
try {
const [accounts, snapshots, totals] = await Promise.all([
listBalanceAccounts(),
listSnapshots(),
getSnapshotTotalsByDate(),
]);
dispatch({
type: "SET_NET_WORTH",
payload: deriveNetWorthTile(accounts.length, snapshots.length > 0, totals),
});
} catch {
dispatch({ type: "SET_NET_WORTH", payload: EMPTY_NET_WORTH });
}
})();
}, []);
const setPeriod = useCallback((period: DashboardPeriod) => { const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period }); dispatch({ type: "SET_PERIOD", payload: period });
@ -135,9 +249,13 @@ export function useDashboard() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []); }, []);
const setBudgetMonth = useCallback((year: number, month: number) => { const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } }); dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []); }, []);
return { state, setPeriod, setCustomDates, setBudgetMonth }; const setAccountIds = useCallback((accountIds: number[]) => {
dispatch({ type: "SET_ACCOUNT_IDS", payload: accountIds });
}, []);
return { state, setPeriod, setCustomDates, setReferencePeriod, setAccountIds };
} }

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"balance": "Balance", "kpiSectionTitle": "Key indicators",
"income": "Income", "income": "Income",
"expenses": "Expenses", "expenses": "Expenses",
"net": "Net", "net": "Net",
@ -50,10 +50,11 @@
"help": { "help": {
"title": "How to use the Dashboard", "title": "How to use the Dashboard",
"tips": [ "tips": [
"Use the period selector (top right) to view different time ranges", "The filter bar at the top picks the date range and which import sources to include",
"Summary cards show your balance, income, and expenses for the selected period", "The key indicators (income, expenses, net, savings rate) cover the chosen reference month, with their change vs the previous month and vs last year",
"The pie chart breaks down your expenses by category", "The \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source",
"Recent transactions are listed at the bottom" "Rising/falling categories and budget adherence summarize the reference month's changes",
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
] ]
} }
}, },
@ -403,8 +404,7 @@
"ytd": "Year-to-Date", "ytd": "Year-to-Date",
"dollarVar": "$ Var", "dollarVar": "$ Var",
"pctVar": "% Var", "pctVar": "% Var",
"noData": "No budget or transaction data for this period.", "noData": "No budget or transaction data for this period."
"titlePrefix": "Budget vs Actual for"
}, },
"export": "Export", "export": "Export",
"month": "Month", "month": "Month",

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"balance": "Solde", "kpiSectionTitle": "Indicateurs clés",
"income": "Revenus", "income": "Revenus",
"expenses": "Dépenses", "expenses": "Dépenses",
"net": "Net", "net": "Net",
@ -50,10 +50,11 @@
"help": { "help": {
"title": "Comment utiliser le tableau de bord", "title": "Comment utiliser le tableau de bord",
"tips": [ "tips": [
"Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates", "Le filtre en haut choisit la plage de dates et les sources d'import à inclure",
"Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée", "Les indicateurs clés (revenus, dépenses, net, taux d'épargne) portent sur le mois de référence choisi, avec leur variation vs le mois précédent et vs l'an dernier",
"Le graphique circulaire détaille vos dépenses par catégorie", "La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import",
"Les transactions récentes sont listées en bas de page" "Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
] ]
} }
}, },
@ -403,8 +404,7 @@
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ Écart", "dollarVar": "$ Écart",
"pctVar": "% Écart", "pctVar": "% Écart",
"noData": "Aucune donnée de budget ou de transaction pour cette période.", "noData": "Aucune donnée de budget ou de transaction pour cette période."
"titlePrefix": "Budget vs Réel pour le mois de"
}, },
"export": "Exporter", "export": "Exporter",
"month": "Mois", "month": "Mois",

View file

@ -1,23 +1,40 @@
// DashboardPage — home page at `/`.
//
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
// row, top movers and budget adherence are the exact Cartes presentational
// components, sourced from the same `getCartesSnapshot` reference-month
// snapshot. Two Dashboard-specific widgets remain: the top-expense-categories
// ranked bar chart (replacing the former expense-only pie) and the net-worth
// tile (from the Bilan — a different metric from every transactional widget
// here, see NetWorthTile's own header comment).
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { useDashboard } from "../hooks/useDashboard"; import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart"; import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner"; import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
import NetWorthTile from "../components/dashboard/NetWorthTile";
import FilterPanel from "../components/reports/FilterPanel";
import KpiCard from "../components/reports/cards/KpiCard";
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
import TopMoversList from "../components/reports/cards/TopMoversList";
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import TransactionDetailModal from "../components/shared/TransactionDetailModal"; import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem } from "../shared/types"; import type { CategoryBreakdownItem } from "../shared/types";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange"; import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
export default function DashboardPage() { export default function DashboardPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard(); const {
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state; state,
setPeriod,
setCustomDates,
setReferencePeriod,
setAccountIds,
} = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null); const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -37,35 +54,6 @@ export default function DashboardPage() {
setDetailModal(item); setDetailModal(item);
}, []); }, []);
const balance = summary.totalAmount;
const balanceColor =
balance > 0
? "text-[var(--positive)]"
: balance < 0
? "text-[var(--negative)]"
: "text-[var(--primary)]";
const cards = [
{
labelKey: "dashboard.balance",
value: fmt.format(balance),
icon: Wallet,
color: balanceColor,
},
{
labelKey: "dashboard.income",
value: fmt.format(summary.incomeTotal),
icon: TrendingUp,
color: "text-[var(--positive)]",
},
{
labelKey: "dashboard.expenses",
value: fmt.format(Math.abs(summary.expenseTotal)),
icon: TrendingDown,
color: "text-[var(--negative)]",
},
];
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]); const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo); const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
@ -73,11 +61,19 @@ export default function DashboardPage() {
return ( return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<CategoriesV1DiscoveryBanner /> <CategoriesV1DiscoveryBanner />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="relative flex items-center gap-3 mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1> <h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<PageHelp helpKey="dashboard" /> <PageHelp helpKey="dashboard" />
</div> </div>
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}
</div>
)}
<FilterPanel
temporalControl={
<PeriodSelector <PeriodSelector
value={period} value={period}
onChange={setPeriod} onChange={setPeriod}
@ -85,46 +81,21 @@ export default function DashboardPage() {
customDateTo={state.customDateTo} customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates} onCustomDateChange={setCustomDates}
/> />
</div> }
accountIds={state.accountIds}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> onAccountIdsChange={setAccountIds}
{cards.map((card) => ( accounts={state.accounts}
<div
key={card.labelKey}
className="bg-[var(--card)] rounded-xl p-5 border border-[var(--border)] shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--muted-foreground)]">
{t(card.labelKey)}
</span>
<card.icon size={20} className={card.color} />
</div>
<p className="text-2xl font-semibold">{card.value}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
<div className="lg:col-span-1">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryPieChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/> />
</div>
<div className="lg:col-span-3"> <div className="flex items-center gap-2 mb-3 flex-wrap">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap"> <h2 className="text-lg font-semibold">{t("dashboard.kpiSectionTitle")}</h2>
{t("reports.bva.titlePrefix")}
<select <select
value={`${state.budgetYear}-${state.budgetMonth}`} value={`${state.referenceYear}-${state.referenceMonth}`}
onChange={(e) => { onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number); const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m); setReferencePeriod(y, m);
}} }}
className="text-base font-semibold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors" className="text-sm font-medium bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
> >
{monthOptions.map((opt) => ( {monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}> <option key={opt.key} value={opt.value}>
@ -132,9 +103,75 @@ export default function DashboardPage() {
</option> </option>
))} ))}
</select> </select>
</h2>
<BudgetVsActualTable data={budgetVsActual} />
</div> </div>
<div className="flex flex-wrap gap-3 mb-6">
<NetWorthTile data={state.netWorth} />
{cartesSnapshot && (
<>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="income"
title={t("reports.cartes.income")}
kpi={cartesSnapshot.kpis.income}
format="currency"
deltaIsBadWhenUp={false}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="expenses"
title={t("reports.cartes.expenses")}
kpi={cartesSnapshot.kpis.expenses}
format="currency"
deltaIsBadWhenUp={true}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="net"
title={t("reports.cartes.net")}
kpi={cartesSnapshot.kpis.net}
format="currency"
deltaIsBadWhenUp={false}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="savingsRate"
title={t("reports.cartes.savingsRate")}
kpi={cartesSnapshot.kpis.savingsRate}
format="percent"
deltaIsBadWhenUp={false}
/>
</div>
</>
)}
</div>
{cartesSnapshot && (
<>
<div className="mb-6">
<IncomeExpenseOverlayChart flow={cartesSnapshot.flow12Months} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
<TopMoversList movers={cartesSnapshot.topMoversUp} direction="up" />
<TopMoversList movers={cartesSnapshot.topMoversDown} direction="down" />
<BudgetAdherenceCard adherence={cartesSnapshot.budgetAdherence} />
</div>
</>
)}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryBarChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
</div> </div>
<div className="mb-6"> <div className="mb-6">

View file

@ -211,7 +211,9 @@ async function getActualsByCategoryRange(
); );
} }
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; // Income-statement reading order: revenue first, then expenses, then
// transfers (Issue #277 — matches COMPARE_TYPE_ORDER / OVER_TIME_TYPE_ORDER).
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/** /**
* Budget vs actual, by category, for the reference month plus YTD. `accountIds` * Budget vs actual, by category, for the reference month plus YTD. `accountIds`

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { getExpensesByCategory } from "./dashboardService"; import { getExpensesByCategory, deriveNetWorthTile } from "./dashboardService";
import type { SnapshotTotalPoint } from "./balance.service";
vi.mock("./db", () => { vi.mock("./db", () => {
const getDb = vi.fn(); const getDb = vi.fn();
@ -63,3 +64,50 @@ describe("getExpensesByCategory — accountIds filter (Issue #273)", () => {
expect(params).toEqual([2]); expect(params).toEqual([2]);
}); });
}); });
describe("deriveNetWorthTile (Issue #279)", () => {
const totals: SnapshotTotalPoint[] = [
{ snapshot_date: "2026-01-31", total: 1000 },
{ snapshot_date: "2026-02-28", total: 1234.56 },
];
it("is hidden when there is no balance account at all", () => {
expect(deriveNetWorthTile(0, false, totals)).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is hidden when accounts exist but no snapshot has ever been recorded (never a misleading $0)", () => {
expect(deriveNetWorthTile(3, false, [])).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is hidden when accounts exist but no snapshot has ever been recorded, even if totals is non-empty (defensive)", () => {
expect(deriveNetWorthTile(3, false, totals)).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is visible with the latest (last) point once accounts and a snapshot both exist", () => {
expect(deriveNetWorthTile(3, true, totals)).toEqual({
visible: true,
total: 1234.56,
asOfDate: "2026-02-28",
});
});
it("stays hidden if the two signals disagree with an empty totals series (defensive guard)", () => {
expect(deriveNetWorthTile(3, true, [])).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
});

View file

@ -1,5 +1,7 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { inPlaceholders } from "../utils/sqlFilters"; import { inPlaceholders } from "../utils/sqlFilters";
import { deriveLandingState } from "../components/balance/balanceLanding";
import type { SnapshotTotalPoint } from "./balance.service";
import type { import type {
DashboardSummary, DashboardSummary,
CategoryBreakdownItem, CategoryBreakdownItem,
@ -164,3 +166,47 @@ export async function getRecentTransactions(
[limit] [limit]
); );
} }
// --- Net-worth tile (Issue #279) ---
//
// The Dashboard's net-worth tile surfaces the Bilan's latest total
// (`getSnapshotTotalsByDate`, balance.service.ts:1767) — a DIFFERENT metric
// from every other Dashboard widget: it is the latest snapshot of account
// balances, not a transaction P&L. It must never be confused with (or scoped
// by) the transactional account/import-source filter, and must never render
// a misleading "$0" before any snapshot has ever been recorded.
export interface NetWorthTileData {
visible: boolean;
total: number | null;
asOfDate: string | null;
}
const EMPTY_NET_WORTH_TILE: NetWorthTileData = {
visible: false,
total: null,
asOfDate: null,
};
/**
* Shapes the net-worth tile from the same two signals BalancePage's landing
* state already uses (`deriveLandingState`) reused here rather than
* re-inferring "no data" from nulls, per the #279 review caveat. The tile
* stays hidden (never a misleading "$0") unless at least one balance account
* exists AND at least one snapshot has ever been recorded; `totals` (the
* `getSnapshotTotalsByDate` series, ascending by date) is otherwise expected
* to carry at least one point, but a defensive check guards a theoretical
* mismatch between the two independent queries.
*/
export function deriveNetWorthTile(
accountsCount: number,
hasAnySnapshot: boolean,
totals: SnapshotTotalPoint[]
): NetWorthTileData {
if (deriveLandingState(accountsCount, hasAnySnapshot) !== "populated") {
return EMPTY_NET_WORTH_TILE;
}
const latest = totals[totals.length - 1];
if (!latest) return EMPTY_NET_WORTH_TILE;
return { visible: true, total: latest.total, asOfDate: latest.snapshot_date };
}

View file

@ -470,13 +470,14 @@ describe("getCartesSnapshot", () => {
expect(worst.overrunPct).toBeCloseTo(75, 5); expect(worst.overrunPct).toBeCloseTo(75, 5);
}); });
// --- accountIds filter (Issue #273) --- // --- accountIds filter (Issues #273 / #279) ---
// //
// getCartesSnapshot must forward accountIds to its two sub-reports that // getCartesSnapshot must forward accountIds to EVERY sub-query so the whole
// dashboard honours an active account filter: the two sub-reports that
// support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData // support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
// for budget adherence) — the review caveat that flagged this: omitting it // for budget adherence) AND the KPI/sparkline/seasonality series
// would let this dashboard silently ignore an active account filter even // (fetchMonthlyFlows, fetchSeasonality). Omitting it anywhere would let the
// though its own building blocks respect it. // dashboard silently mix filtered and unfiltered figures on the same screen.
it("forwards accountIds to the compare (top movers) sub-report", async () => { it("forwards accountIds to the compare (top movers) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([])); mockSelect.mockImplementation(() => Promise.resolve([]));
@ -506,6 +507,32 @@ describe("getCartesSnapshot", () => {
} }
}); });
it("forwards accountIds to the monthly-flows series (KPIs, sparklines, overlay)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchMonthlyFlows is the only snapshot query selecting a "%Y-%m" month.
const flowCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%Y-%m', date) AS month"),
)!;
expect(flowCall[0]).toContain("AND source_id IN ($3, $4)");
expect(flowCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the seasonality series", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchSeasonality is the only snapshot query filtering on a "%m" month.
const seasonalityCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%m', date) = $1"),
)!;
expect(seasonalityCall[0]).toContain("AND source_id IN ($4, $5)");
expect(seasonalityCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => { it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([])); mockSelect.mockImplementation(() => Promise.resolve([]));

View file

@ -1186,18 +1186,25 @@ interface RawMonthFlow {
async function fetchMonthlyFlows( async function fetchMonthlyFlows(
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string,
accountIds?: number[],
): Promise<RawMonthFlow[]> { ): Promise<RawMonthFlow[]> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, 3);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawMonthFlow[]>( return db.select<RawMonthFlow[]>(
`SELECT `SELECT
strftime('%Y-%m', date) AS month, strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions FROM transactions
WHERE date >= $1 AND date <= $2 WHERE date >= $1 AND date <= $2${accountClause}
GROUP BY month GROUP BY month
ORDER BY month ASC`, ORDER BY month ASC`,
[dateFrom, dateTo], params,
); );
} }
@ -1210,9 +1217,16 @@ async function fetchSeasonality(
month: number, month: number,
yearFrom: number, yearFrom: number,
yearTo: number, yearTo: number,
accountIds?: number[],
): Promise<RawSeasonalityRow[]> { ): Promise<RawSeasonalityRow[]> {
const db = await getDb(); const db = await getDb();
const mm = String(month).padStart(2, "0"); const mm = String(month).padStart(2, "0");
const params: unknown[] = [mm, yearFrom, yearTo];
const accountPlaceholders = inPlaceholders(accountIds, 4);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawSeasonalityRow[]>( return db.select<RawSeasonalityRow[]>(
`SELECT `SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year, CAST(strftime('%Y', date) AS INTEGER) AS year,
@ -1220,10 +1234,10 @@ async function fetchSeasonality(
FROM transactions FROM transactions
WHERE strftime('%m', date) = $1 WHERE strftime('%m', date) = $1
AND CAST(strftime('%Y', date) AS INTEGER) >= $2 AND CAST(strftime('%Y', date) AS INTEGER) >= $2
AND CAST(strftime('%Y', date) AS INTEGER) <= $3 AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause}
GROUP BY year GROUP BY year
ORDER BY year DESC`, ORDER BY year DESC`,
[mm, yearFrom, yearTo], params,
); );
} }
@ -1240,12 +1254,12 @@ async function fetchSeasonality(
* 4. Budget vs actual for the reference month. * 4. Budget vs actual for the reference month.
* 5. Seasonality: same calendar month across the two prior years. * 5. Seasonality: same calendar month across the two prior years.
* *
* `accountIds` (Issue #273) is forwarded to the two sub-reports that already * `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
* support it (`getCompareMonthOverMonth` for top movers, `getBudgetVsActualData` * dashboard honours an active account filter: the top-movers
* for budget adherence) omitting it here would let this dashboard silently * (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
* ignore an active account filter even though its building blocks respect it. * and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
* The KPI/sparkline/seasonality series (`fetchMonthlyFlows`, `fetchSeasonality`) * `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
* do not take an account filter yet; that is out of scope for this issue. * filtered and unfiltered figures on the same screen.
*/ */
export async function getCartesSnapshot( export async function getCartesSnapshot(
referenceYear: number, referenceYear: number,
@ -1261,8 +1275,8 @@ export async function getCartesSnapshot(
// Seasonality range: previous 2 years for the same calendar month. // Seasonality range: previous 2 years for the same calendar month.
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([ const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1), fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds),
fetchMonthlyFlows(windowStartIso, refEnd), fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds), getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
getBudgetVsActualData(referenceYear, referenceMonth, accountIds), getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
]); ]);