From 193ef1d9dd4cac508dd7db8c2da4c83588443cf3 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 11 Jul 2026 17:13:23 -0400 Subject: [PATCH] feat(dashboard): converge home page on the Cartes report model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + docs/architecture.md | 8 +- src/components/dashboard/NetWorthTile.tsx | 62 ++++++ src/hooks/useDashboard.ts | 190 ++++++++++++++---- src/i18n/locales/en.json | 14 +- src/i18n/locales/fr.json | 14 +- src/pages/DashboardPage.tsx | 227 +++++++++++++--------- src/services/dashboardService.test.ts | 50 ++++- src/services/dashboardService.ts | 46 +++++ 10 files changed, 463 insertions(+), 150 deletions(-) create mode 100644 src/components/dashboard/NetWorthTile.tsx diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 16016c8..f7c77ed 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -19,6 +19,7 @@ ### 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). +- 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d2f68..e376ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### 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). +- 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 diff --git a/docs/architecture.md b/docs/architecture.md index ec401fe..4b55a4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`) | | `adjustmentService.ts` | Gestion des ajustements | | `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) | | `dataExportService.ts` | Export de données (chiffré) | | `userPreferenceService.ts` | Stockage préférences utilisateur | @@ -205,8 +205,8 @@ Chaque hook encapsule la logique d'état via `useReducer` : | `useImportHistory` | Historique des imports | | `useAdjustments` | Ajustements | | `useBudget` | Budget | -| `useDashboard` | Métriques du tableau de bord | -| `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 `` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276) — reste Dashboard/Hub (M2) | +| `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 `` (#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 `` (#279) | | `useHighlights` | Panneau de faits saillants du hub rapports | | `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) | @@ -350,7 +350,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App | 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 | | `/transactions` | `TransactionsPage` | Liste avec filtres | | `/categories` | `CategoriesPage` | Gestion hiérarchique | diff --git a/src/components/dashboard/NetWorthTile.tsx b/src/components/dashboard/NetWorthTile.tsx new file mode 100644 index 0000000..68b89ba --- /dev/null +++ b/src/components/dashboard/NetWorthTile.tsx @@ -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 ( + +
+
+
+ {formatCurrency(data.total, i18n.language)} +
+
+ {t("balance.overview.asOf", { date: formattedDate })} +
+ + ); +} diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts index 93eb1ea..0d5c2c8 100644 --- a/src/hooks/useDashboard.ts +++ b/src/hooks/useDashboard.ts @@ -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 type { DashboardPeriod, - DashboardSummary, CategoryBreakdownItem, CategoryOverTimeData, - BudgetVsActualRow, + CartesSnapshot, + ImportSource, } from "../shared/types"; import { - getDashboardSummary, getExpensesByCategory, + deriveNetWorthTile, + type NetWorthTileData, } from "../services/dashboardService"; -import { getCategoryOverTime } from "../services/reportService"; -import { getBudgetVsActualData } from "../services/budgetService"; +import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService"; +import { getAllImportSources } from "../services/transactionService"; +import { + getSnapshotTotalsByDate, + listSnapshots, + listBalanceAccounts, +} from "../services/balance.service"; import { computeDateRange } from "../utils/dateRange"; +import { defaultReferencePeriod } from "../utils/referencePeriod"; interface DashboardState { - summary: DashboardSummary; categoryBreakdown: CategoryBreakdownItem[]; categoryOverTime: CategoryOverTimeData; - budgetVsActual: BudgetVsActualRow[]; + cartesSnapshot: CartesSnapshot | null; period: DashboardPeriod; - budgetYear: number; - budgetMonth: number; customDateFrom: string; customDateTo: string; + referenceYear: number; + referenceMonth: number; + accountIds: number[]; + accounts: ImportSource[]; + netWorth: NetWorthTileData; isLoading: boolean; error: string | null; } @@ -34,30 +68,46 @@ type DashboardAction = | { type: "SET_DATA"; payload: { - summary: DashboardSummary; categoryBreakdown: CategoryBreakdownItem[]; categoryOverTime: CategoryOverTimeData; - budgetVsActual: BudgetVsActualRow[]; + cartesSnapshot: CartesSnapshot; }; } | { 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 todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; 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 = { - summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }, categoryBreakdown: [], - categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] }, - budgetVsActual: [], + categoryOverTime: EMPTY_CATEGORY_OVER_TIME, + cartesSnapshot: null, period: "year", - budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(), - budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(), customDateFrom: yearStartStr, customDateTo: todayStr, + referenceYear: defaultRef.year, + referenceMonth: defaultRef.month, + accountIds: [], + accounts: [], + netWorth: EMPTY_NET_WORTH, isLoading: false, error: null, }; @@ -71,18 +121,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState case "SET_DATA": return { ...state, - summary: action.payload.summary, categoryBreakdown: action.payload.categoryBreakdown, categoryOverTime: action.payload.categoryOverTime, - budgetVsActual: action.payload.budgetVsActual, + cartesSnapshot: action.payload.cartesSnapshot, isLoading: false, }; case "SET_PERIOD": return { ...state, period: action.payload }; - case "SET_BUDGET_MONTH": - return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month }; 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: return state; } @@ -96,8 +156,9 @@ export function useDashboard() { period: DashboardPeriod, customFrom: string | undefined, customTo: string | undefined, - bYear: number, - bMonth: number, + refYear: number, + refMonth: number, + accountIds: number[], ) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); @@ -105,15 +166,17 @@ export function useDashboard() { try { const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); - const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([ - getDashboardSummary(dateFrom, dateTo), - getExpensesByCategory(dateFrom, dateTo), - getCategoryOverTime(dateFrom, dateTo), - getBudgetVsActualData(bYear, bMonth), + const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([ + getExpensesByCategory(dateFrom, dateTo, accountIds), + // typeFilter "expense" (Issue #279): this chart is titled "expenses + // over time" but previously aggregated every category type — a + // 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; - dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } }); + dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } }); } catch (e) { if (fetchId !== fetchIdRef.current) return; dispatch({ @@ -124,8 +187,59 @@ export function useDashboard() { }, []); useEffect(() => { - fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth); - }, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]); + 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) => { dispatch({ type: "SET_PERIOD", payload: period }); @@ -135,9 +249,13 @@ export function useDashboard() { dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); }, []); - const setBudgetMonth = useCallback((year: number, month: number) => { - dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } }); + const setReferencePeriod = useCallback((year: number, month: number) => { + 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 }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0754121..3a752b6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -20,7 +20,7 @@ }, "dashboard": { "title": "Dashboard", - "balance": "Balance", + "kpiSectionTitle": "Key indicators", "income": "Income", "expenses": "Expenses", "net": "Net", @@ -50,10 +50,11 @@ "help": { "title": "How to use the Dashboard", "tips": [ - "Use the period selector (top right) to view different time ranges", - "Summary cards show your balance, income, and expenses for the selected period", - "The pie chart breaks down your expenses by category", - "Recent transactions are listed at the bottom" + "The filter bar at the top picks the date range and which import sources to include", + "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 \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source", + "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", "dollarVar": "$ Var", "pctVar": "% Var", - "noData": "No budget or transaction data for this period.", - "titlePrefix": "Budget vs Actual for" + "noData": "No budget or transaction data for this period." }, "export": "Export", "month": "Month", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 6fd7b3b..bfbb24a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -20,7 +20,7 @@ }, "dashboard": { "title": "Tableau de bord", - "balance": "Solde", + "kpiSectionTitle": "Indicateurs clés", "income": "Revenus", "expenses": "Dépenses", "net": "Net", @@ -50,10 +50,11 @@ "help": { "title": "Comment utiliser le tableau de bord", "tips": [ - "Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates", - "Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée", - "Le graphique circulaire détaille vos dépenses par catégorie", - "Les transactions récentes sont listées en bas de page" + "Le filtre en haut choisit la plage de dates et les sources d'import à inclure", + "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", + "La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import", + "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", "dollarVar": "$ Écart", "pctVar": "% Écart", - "noData": "Aucune donnée de budget ou de transaction pour cette période.", - "titlePrefix": "Budget vs Réel pour le mois de" + "noData": "Aucune donnée de budget ou de transaction pour cette période." }, "export": "Exporter", "month": "Mois", diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 7f090ae..58ac6f4 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -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 { useTranslation } from "react-i18next"; -import { Wallet, TrendingUp, TrendingDown } from "lucide-react"; import { useDashboard } from "../hooks/useDashboard"; import { PageHelp } from "../components/shared/PageHelp"; 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 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 BudgetVsActualTable from "../components/reports/BudgetVsActualTable"; import TransactionDetailModal from "../components/shared/TransactionDetailModal"; import type { CategoryBreakdownItem } from "../shared/types"; import { computeDateRange, buildMonthOptions } from "../utils/dateRange"; -const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }); - export default function DashboardPage() { const { t, i18n } = useTranslation(); - const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard(); - const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state; + const { + state, + setPeriod, + setCustomDates, + setReferencePeriod, + setAccountIds, + } = useDashboard(); + const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state; const [hiddenCategories, setHiddenCategories] = useState>(new Set()); const [detailModal, setDetailModal] = useState(null); @@ -37,35 +54,6 @@ export default function DashboardPage() { 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 { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo); @@ -73,68 +61,117 @@ export default function DashboardPage() { return (
-
-
-

{t("dashboard.title")}

- +
+

{t("dashboard.title")}

+ +
+ + {state.error && ( +
+ {state.error}
- -
+ )} -
- {cards.map((card) => ( -
-
- - {t(card.labelKey)} - - -
-

{card.value}

-
- ))} -
- -
-
-

{t("dashboard.expensesByCategory")}

- -
-
-

- {t("reports.bva.titlePrefix")} - -

- -
+ } + accountIds={state.accountIds} + onAccountIdsChange={setAccountIds} + accounts={state.accounts} + /> + +
+

{t("dashboard.kpiSectionTitle")}

+ +
+ +
+ + {cartesSnapshot && ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+ + )} +
+ + {cartesSnapshot && ( + <> +
+ +
+ +
+ + + +
+ + )} + +
+

{t("dashboard.expensesByCategory")}

+
diff --git a/src/services/dashboardService.test.ts b/src/services/dashboardService.test.ts index e00060b..0baaf58 100644 --- a/src/services/dashboardService.test.ts +++ b/src/services/dashboardService.test.ts @@ -1,5 +1,6 @@ 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", () => { const getDb = vi.fn(); @@ -63,3 +64,50 @@ describe("getExpensesByCategory — accountIds filter (Issue #273)", () => { 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, + }); + }); +}); diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts index 41f45ca..6496cc6 100644 --- a/src/services/dashboardService.ts +++ b/src/services/dashboardService.ts @@ -1,5 +1,7 @@ import { getDb } from "./db"; import { inPlaceholders } from "../utils/sqlFilters"; +import { deriveLandingState } from "../components/balance/balanceLanding"; +import type { SnapshotTotalPoint } from "./balance.service"; import type { DashboardSummary, CategoryBreakdownItem, @@ -164,3 +166,47 @@ export async function getRecentTransactions( [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 }; +}