Merge pull request 'feat(balance): /balance page + evolution chart + sidebar (#141)' (#150) from issue-141-bilan-3 into main
This commit is contained in:
commit
47ecf886d2
15 changed files with 1341 additions and 0 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
||||||
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
||||||
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
|
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
|
||||||
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
|
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
||||||
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
||||||
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
|
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
|
||||||
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
|
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import AccountsPage from "./pages/AccountsPage";
|
import AccountsPage from "./pages/AccountsPage";
|
||||||
|
import BalancePage from "./pages/BalancePage";
|
||||||
import SnapshotEditPage from "./pages/SnapshotEditPage";
|
import SnapshotEditPage from "./pages/SnapshotEditPage";
|
||||||
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||||
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
||||||
|
|
@ -116,6 +117,7 @@ export default function App() {
|
||||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/balance" element={<BalancePage />} />
|
||||||
<Route path="/balance/accounts" element={<AccountsPage />} />
|
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||||
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
170
src/components/balance/BalanceAccountsTable.tsx
Normal file
170
src/components/balance/BalanceAccountsTable.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// BalanceAccountsTable — one-row-per-active-account table on /balance.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Columns:
|
||||||
|
// - Account name + category label
|
||||||
|
// - Latest snapshot value (or "—" when no snapshot exists yet)
|
||||||
|
// - Δ% over the active period (latest value vs the period-anchor value;
|
||||||
|
// null when no anchor exists, rendered as "—").
|
||||||
|
// - Actions menu (Detail no-op for now, Archive via service).
|
||||||
|
//
|
||||||
|
// Future return-metric columns (3M / 1A / since-creation / unadjusted)
|
||||||
|
// land in Issue #142. They have a TODO marker below.
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Archive, MoreVertical } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
AccountLatestSnapshot,
|
||||||
|
AccountPeriodAnchor,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
|
||||||
|
const cadFormatter = (locale: string) =>
|
||||||
|
new Intl.NumberFormat(locale, {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BalanceAccountsTableProps {
|
||||||
|
accounts: AccountLatestSnapshot[];
|
||||||
|
periodAnchor: AccountPeriodAnchor[];
|
||||||
|
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceAccountsTable({
|
||||||
|
accounts,
|
||||||
|
periodAnchor,
|
||||||
|
onArchiveAccount,
|
||||||
|
}: BalanceAccountsTableProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
||||||
|
|
||||||
|
/** account_id → period anchor (start-of-period value). */
|
||||||
|
const anchorMap = useMemo(() => {
|
||||||
|
const m = new Map<number, AccountPeriodAnchor>();
|
||||||
|
for (const a of periodAnchor) m.set(a.account_id, a);
|
||||||
|
return m;
|
||||||
|
}, [periodAnchor]);
|
||||||
|
|
||||||
|
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("balance.overview.noAccounts")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[var(--muted)]/30">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
|
{t("balance.account.fields.name")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
|
{t("balance.account.fields.category")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">
|
||||||
|
{t("balance.overview.latestValue")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">
|
||||||
|
{t("balance.overview.periodDelta")}
|
||||||
|
</th>
|
||||||
|
{/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */}
|
||||||
|
<th className="text-right px-4 py-3 font-medium w-12">
|
||||||
|
{t("balance.account.fields.actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((acc) => {
|
||||||
|
const anchor = anchorMap.get(acc.account_id);
|
||||||
|
const deltaPct =
|
||||||
|
acc.latest_value !== null && anchor && anchor.anchor_value !== 0
|
||||||
|
? ((acc.latest_value - anchor.anchor_value) /
|
||||||
|
Math.abs(anchor.anchor_value)) *
|
||||||
|
100
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={acc.account_id}
|
||||||
|
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
{acc.account_name}
|
||||||
|
{acc.symbol ? (
|
||||||
|
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||||
|
({acc.symbol})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--muted-foreground)]">
|
||||||
|
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{deltaPct !== null ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
deltaPct >= 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: "text-[var(--negative)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deltaPct >= 0 ? "+" : ""}
|
||||||
|
{deltaPct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenMenuFor(
|
||||||
|
openMenuFor === acc.account_id ? null : acc.account_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-1 rounded hover:bg-[var(--muted)]/40"
|
||||||
|
aria-label={t("balance.account.fields.actions")}
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
{openMenuFor === acc.account_id && (
|
||||||
|
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[160px] text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
|
title={t("balance.overview.detailComingSoon")}
|
||||||
|
>
|
||||||
|
{t("balance.overview.detailAction")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuFor(null);
|
||||||
|
onArchiveAccount?.(acc);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
||||||
|
>
|
||||||
|
<Archive size={14} />
|
||||||
|
{t("balance.account.actions.archive")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/components/balance/BalanceEvolutionChart.tsx
Normal file
218
src/components/balance/BalanceEvolutionChart.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
// BalanceEvolutionChart — line / stacked-area chart of net worth over time.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Reuses the established Recharts patterns from the
|
||||||
|
// reports/* charts (see decisions-log #141 — native SVG was reconsidered;
|
||||||
|
// Recharts is the single chart pattern in this codebase). Two modes:
|
||||||
|
// - 'line' : a single LineChart of `SUM(value)` per snapshot date.
|
||||||
|
// - 'stacked' : an AreaChart with one Area per category (stackId='all').
|
||||||
|
//
|
||||||
|
// Tooltip shows per-category breakdown in stacked mode and just the total in
|
||||||
|
// line mode.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import type {
|
||||||
|
SnapshotTotalPoint,
|
||||||
|
SnapshotCategoryBreakdownPoint,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
|
||||||
|
|
||||||
|
// Stable palette for the stacked-by-category areas. Indexed deterministically
|
||||||
|
// by category sort order so the colour assignment stays consistent across
|
||||||
|
// renders and period changes. Reused from the reports CategoryBarChart palette.
|
||||||
|
const CATEGORY_PALETTE = [
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#10b981", // emerald
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#ef4444", // red
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#84cc16", // lime
|
||||||
|
"#f97316", // orange
|
||||||
|
"#6366f1", // indigo
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface BalanceEvolutionChartProps {
|
||||||
|
mode: BalanceChartMode;
|
||||||
|
totals: SnapshotTotalPoint[];
|
||||||
|
byCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
|
/** Map category_key → translated label so the legend reads naturally. */
|
||||||
|
categoryLabels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceEvolutionChart({
|
||||||
|
mode,
|
||||||
|
totals,
|
||||||
|
byCategory,
|
||||||
|
categoryLabels = {},
|
||||||
|
}: BalanceEvolutionChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const cadFormatter = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}),
|
||||||
|
[i18n.language]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString(dateLocale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Line-mode dataset ---
|
||||||
|
const lineData = useMemo(
|
||||||
|
() =>
|
||||||
|
totals.map((p) => ({
|
||||||
|
snapshot_date: p.snapshot_date,
|
||||||
|
total: p.total,
|
||||||
|
})),
|
||||||
|
[totals]
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Stacked-area dataset ---
|
||||||
|
// We transpose the per-snapshot bucket into one row per snapshot_date with
|
||||||
|
// one column per category_key. Categories absent at a snapshot date are
|
||||||
|
// emitted as 0 so Recharts renders a continuous stack.
|
||||||
|
const { stackedData, categoryKeys } = useMemo(() => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const point of byCategory) {
|
||||||
|
for (const k of Object.keys(point.byCategory)) keys.add(k);
|
||||||
|
}
|
||||||
|
const orderedKeys = Array.from(keys).sort();
|
||||||
|
const data = byCategory.map((point) => {
|
||||||
|
const row: Record<string, string | number> = {
|
||||||
|
snapshot_date: point.snapshot_date,
|
||||||
|
};
|
||||||
|
for (const k of orderedKeys) {
|
||||||
|
row[k] = point.byCategory[k] ?? 0;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return { stackedData: data, categoryKeys: orderedKeys };
|
||||||
|
}, [byCategory]);
|
||||||
|
|
||||||
|
const isEmpty =
|
||||||
|
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] italic py-12">
|
||||||
|
{t("balance.chart.empty")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipContentStyle = {
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={360}>
|
||||||
|
{mode === "line" ? (
|
||||||
|
<LineChart
|
||||||
|
data={lineData}
|
||||||
|
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="snapshot_date"
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(s: string) => formatDate(s)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v: number) => cadFormatter.format(v)}
|
||||||
|
width={88}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined) =>
|
||||||
|
cadFormatter.format(value ?? 0)
|
||||||
|
}
|
||||||
|
labelFormatter={(label) => formatDate(String(label))}
|
||||||
|
contentStyle={tooltipContentStyle}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
name={t("balance.chart.totalSeriesLabel")}
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
) : (
|
||||||
|
<AreaChart
|
||||||
|
data={stackedData}
|
||||||
|
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="snapshot_date"
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(s: string) => formatDate(s)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v: number) => cadFormatter.format(v)}
|
||||||
|
width={88}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined, name) => [
|
||||||
|
cadFormatter.format(value ?? 0),
|
||||||
|
categoryLabels[String(name)] ?? String(name),
|
||||||
|
]}
|
||||||
|
labelFormatter={(label) => formatDate(String(label))}
|
||||||
|
contentStyle={tooltipContentStyle}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
|
||||||
|
/>
|
||||||
|
{categoryKeys.map((key, idx) => (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
stackId="all"
|
||||||
|
stroke={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
|
||||||
|
fill={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
|
||||||
|
fillOpacity={0.5}
|
||||||
|
name={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/balance/BalanceOverviewCard.tsx
Normal file
128
src/components/balance/BalanceOverviewCard.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// BalanceOverviewCard — top summary tile of /balance.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Displays:
|
||||||
|
// - The latest aggregate snapshot total (sum across all accounts on the
|
||||||
|
// most recent snapshot date).
|
||||||
|
// - Δ% versus the previous chronological snapshot (null when only one
|
||||||
|
// snapshot exists; rendered as "—").
|
||||||
|
// - A staleness warning when the latest snapshot is older than 60 days.
|
||||||
|
// - "+ Nouveau snapshot" CTA → `/balance/snapshot`.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { SnapshotTotalPoint } from "../../services/balance.service";
|
||||||
|
|
||||||
|
const STALENESS_DAYS = 60;
|
||||||
|
const cadFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
interface BalanceOverviewCardProps {
|
||||||
|
/** The full evolution series for the active period (latest at the end). */
|
||||||
|
totals: SnapshotTotalPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (totals.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const last = totals[totals.length - 1];
|
||||||
|
const prev = totals.length >= 2 ? totals[totals.length - 2] : null;
|
||||||
|
const deltaPct =
|
||||||
|
prev && prev.total !== 0
|
||||||
|
? ((last.total - prev.total) / Math.abs(prev.total)) * 100
|
||||||
|
: null;
|
||||||
|
const ageMs = Date.now() - new Date(last.snapshot_date).getTime();
|
||||||
|
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
|
||||||
|
return {
|
||||||
|
latest: last,
|
||||||
|
deltaPct,
|
||||||
|
isStale: ageDays > STALENESS_DAYS,
|
||||||
|
ageDays,
|
||||||
|
};
|
||||||
|
}, [totals]);
|
||||||
|
|
||||||
|
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString(dateLocale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.overview.latestTotal")}
|
||||||
|
</p>
|
||||||
|
{summary ? (
|
||||||
|
<>
|
||||||
|
<p className="text-3xl font-bold mt-1">
|
||||||
|
{cadFormatter(summary.latest.total)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||||
|
{t("balance.overview.asOf", {
|
||||||
|
date: formatDate(summary.latest.snapshot_date),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-2">
|
||||||
|
{t("balance.overview.noSnapshots")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-stretch sm:items-end gap-2">
|
||||||
|
{summary && summary.deltaPct !== null && (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1 text-sm font-medium ${
|
||||||
|
summary.deltaPct >= 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: "text-[var(--negative)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{summary.deltaPct >= 0 ? (
|
||||||
|
<TrendingUp size={16} />
|
||||||
|
) : (
|
||||||
|
<TrendingDown size={16} />
|
||||||
|
)}
|
||||||
|
{summary.deltaPct >= 0 ? "+" : ""}
|
||||||
|
{summary.deltaPct.toFixed(2)}%
|
||||||
|
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
|
||||||
|
{t("balance.overview.vsPrevious")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/balance/snapshot"
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("balance.overview.newSnapshot")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary?.isStale && (
|
||||||
|
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/30 text-sm">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t("balance.overview.staleWarning", { days: summary.ageDays })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Wallet,
|
||||||
Settings,
|
Settings,
|
||||||
Languages,
|
Languages,
|
||||||
Moon,
|
Moon,
|
||||||
|
|
@ -25,6 +26,7 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Wallet,
|
||||||
Settings,
|
Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
39
src/hooks/useBalanceOverview.test.ts
Normal file
39
src/hooks/useBalanceOverview.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeBalanceDateRange } from "./useBalanceOverview";
|
||||||
|
|
||||||
|
const FIXED_TODAY = new Date(2026, 3, 25); // local 2026-04-25
|
||||||
|
|
||||||
|
describe("computeBalanceDateRange", () => {
|
||||||
|
it("returns an empty range for 'all'", () => {
|
||||||
|
expect(computeBalanceDateRange("all", FIXED_TODAY)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts 90 days for 3M and emits a from-only range", () => {
|
||||||
|
const r = computeBalanceDateRange("3M", FIXED_TODAY);
|
||||||
|
expect(r.to).toBeUndefined();
|
||||||
|
expect(r.from).toBe("2026-01-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts 180 days for 6M", () => {
|
||||||
|
const r = computeBalanceDateRange("6M", FIXED_TODAY);
|
||||||
|
expect(r.from).toBe("2025-10-27");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts 365 days for 1A", () => {
|
||||||
|
const r = computeBalanceDateRange("1A", FIXED_TODAY);
|
||||||
|
expect(r.from).toBe("2025-04-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts 1095 days for 3A", () => {
|
||||||
|
const r = computeBalanceDateRange("3A", FIXED_TODAY);
|
||||||
|
expect(r.from).toBe("2023-04-26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits ISO-8601 zero-padded month/day", () => {
|
||||||
|
// 2026-01-05 → 3M → 2025-10-07; both fields zero-padded.
|
||||||
|
const today = new Date(2026, 0, 5);
|
||||||
|
const r = computeBalanceDateRange("3M", today);
|
||||||
|
expect(r.from).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(r.from).toBe("2025-10-07");
|
||||||
|
});
|
||||||
|
});
|
||||||
168
src/hooks/useBalanceOverview.ts
Normal file
168
src/hooks/useBalanceOverview.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
// useBalanceOverview — scoped useReducer hook backing BalancePage.
|
||||||
|
//
|
||||||
|
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141):
|
||||||
|
// - Time-series for the evolution chart (totals + per-category breakdown)
|
||||||
|
// - Per-account latest snapshot value + period-anchor value (for Δ%)
|
||||||
|
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
||||||
|
// - Chart mode toggle (line / stacked-area)
|
||||||
|
//
|
||||||
|
// Returns are intentionally OUT of scope here — they ship in Issue #142
|
||||||
|
// (Modified Dietz). The accounts table reserves columns for the return
|
||||||
|
// metrics with TODO comments.
|
||||||
|
|
||||||
|
import { useReducer, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getSnapshotTotalsByDate,
|
||||||
|
getSnapshotTotalsByCategoryAndDate,
|
||||||
|
getAccountsLatestSnapshot,
|
||||||
|
getAccountsPeriodAnchor,
|
||||||
|
type SnapshotTotalPoint,
|
||||||
|
type SnapshotCategoryBreakdownPoint,
|
||||||
|
type AccountLatestSnapshot,
|
||||||
|
type AccountPeriodAnchor,
|
||||||
|
type SnapshotDateRange,
|
||||||
|
} from "../services/balance.service";
|
||||||
|
|
||||||
|
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
|
||||||
|
export type BalanceChartMode = "line" | "stacked";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
period: BalancePeriod;
|
||||||
|
chartMode: BalanceChartMode;
|
||||||
|
evolutionTotals: SnapshotTotalPoint[];
|
||||||
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
||||||
|
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| {
|
||||||
|
type: "LOAD_SUCCESS";
|
||||||
|
payload: {
|
||||||
|
evolutionTotals: SnapshotTotalPoint[];
|
||||||
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "LOAD_ERROR"; payload: string };
|
||||||
|
|
||||||
|
function initialState(): State {
|
||||||
|
return {
|
||||||
|
period: "1A",
|
||||||
|
chartMode: "line",
|
||||||
|
evolutionTotals: [],
|
||||||
|
evolutionByCategory: [],
|
||||||
|
accountsLatest: [],
|
||||||
|
accountsPeriodAnchor: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_PERIOD":
|
||||||
|
return { ...state, period: action.payload };
|
||||||
|
case "SET_CHART_MODE":
|
||||||
|
return { ...state, chartMode: action.payload };
|
||||||
|
case "LOAD_START":
|
||||||
|
return { ...state, isLoading: true, error: null };
|
||||||
|
case "LOAD_SUCCESS":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...action.payload,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
case "LOAD_ERROR":
|
||||||
|
return { ...state, isLoading: false, error: action.payload };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper: turn a `BalancePeriod` into a `SnapshotDateRange` anchored on
|
||||||
|
* the supplied `today` (defaults to now). Exported so the unit tests can
|
||||||
|
* exercise the date math without mocking time.
|
||||||
|
*
|
||||||
|
* Period anchor decision (decisions-log #141): we anchor on `today`, not on
|
||||||
|
* the latest snapshot. Aggregators read snapshot rows so the answer is
|
||||||
|
* identical either way, but anchoring on today keeps the chart's right edge
|
||||||
|
* stable as the user enters new snapshots — intuitive UX.
|
||||||
|
*/
|
||||||
|
export function computeBalanceDateRange(
|
||||||
|
period: BalancePeriod,
|
||||||
|
today: Date = new Date()
|
||||||
|
): SnapshotDateRange {
|
||||||
|
if (period === "all") return {};
|
||||||
|
const days =
|
||||||
|
period === "3M" ? 90 : period === "6M" ? 180 : period === "1A" ? 365 : 1095;
|
||||||
|
const from = new Date(today);
|
||||||
|
from.setDate(from.getDate() - days);
|
||||||
|
// Local-civil `YYYY-MM-DD` (matches normalizeSnapshotDate's expectations).
|
||||||
|
const yyyy = from.getFullYear();
|
||||||
|
const mm = String(from.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(from.getDate()).padStart(2, "0");
|
||||||
|
return { from: `${yyyy}-${mm}-${dd}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseBalanceOverviewResult {
|
||||||
|
state: State;
|
||||||
|
setPeriod: (period: BalancePeriod) => void;
|
||||||
|
setChartMode: (mode: BalanceChartMode) => void;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBalanceOverview(): UseBalanceOverviewResult {
|
||||||
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||||
|
|
||||||
|
const load = useCallback(async (period: BalancePeriod) => {
|
||||||
|
dispatch({ type: "LOAD_START" });
|
||||||
|
try {
|
||||||
|
const range = computeBalanceDateRange(period);
|
||||||
|
// Parallel fetches — no inter-dependency between the four queries.
|
||||||
|
const [totals, byCategory, latest, anchors] = await Promise.all([
|
||||||
|
getSnapshotTotalsByDate(range),
|
||||||
|
getSnapshotTotalsByCategoryAndDate(range),
|
||||||
|
getAccountsLatestSnapshot(),
|
||||||
|
getAccountsPeriodAnchor(range),
|
||||||
|
]);
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_SUCCESS",
|
||||||
|
payload: {
|
||||||
|
evolutionTotals: totals,
|
||||||
|
evolutionByCategory: byCategory,
|
||||||
|
accountsLatest: latest,
|
||||||
|
accountsPeriodAnchor: anchors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
dispatch({ type: "LOAD_ERROR", payload: message });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reload whenever the period changes (and on mount).
|
||||||
|
useEffect(() => {
|
||||||
|
void load(state.period);
|
||||||
|
}, [state.period, load]);
|
||||||
|
|
||||||
|
const setPeriod = useCallback((period: BalancePeriod) => {
|
||||||
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setChartMode = useCallback((mode: BalanceChartMode) => {
|
||||||
|
dispatch({ type: "SET_CHART_MODE", payload: mode });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(() => load(state.period), [load, state.period]);
|
||||||
|
|
||||||
|
return { state, setPeriod, setChartMode, reload };
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"adjustments": "Adjustments",
|
"adjustments": "Adjustments",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
|
"balance": "Balance sheet",
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -1452,6 +1453,39 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
|
"overview": {
|
||||||
|
"title": "Balance sheet",
|
||||||
|
"latestTotal": "Current net worth",
|
||||||
|
"asOf": "as of {{date}}",
|
||||||
|
"noSnapshots": "No snapshot yet. Create one to start tracking your balance over time.",
|
||||||
|
"vsPrevious": "vs previous",
|
||||||
|
"newSnapshot": "New snapshot",
|
||||||
|
"staleWarning": "The latest snapshot is more than {{days}} days old. Consider updating it to keep your balance accurate.",
|
||||||
|
"latestValue": "Latest value",
|
||||||
|
"periodDelta": "Δ% over period",
|
||||||
|
"noAccounts": "No active accounts. Create a balance account to get started.",
|
||||||
|
"accountsTitle": "Accounts",
|
||||||
|
"detailAction": "Details",
|
||||||
|
"detailComingSoon": "Available in a future release."
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"legend": "Analysis period",
|
||||||
|
"3M": "3 months",
|
||||||
|
"6M": "6 months",
|
||||||
|
"1A": "1 year",
|
||||||
|
"3A": "3 years",
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"empty": "No snapshot for this period.",
|
||||||
|
"modeLegend": "Chart display mode",
|
||||||
|
"totalSeriesLabel": "Total",
|
||||||
|
"mode": {
|
||||||
|
"line": "Line",
|
||||||
|
"stacked": "Stacked by category"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": "Balance sheet",
|
||||||
"accountsPage": {
|
"accountsPage": {
|
||||||
"title": "Balance accounts",
|
"title": "Balance accounts",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"adjustments": "Ajustements",
|
"adjustments": "Ajustements",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Rapports",
|
"reports": "Rapports",
|
||||||
|
"balance": "Bilan",
|
||||||
"settings": "Paramètres"
|
"settings": "Paramètres"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -1452,6 +1453,39 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
|
"overview": {
|
||||||
|
"title": "Bilan",
|
||||||
|
"latestTotal": "Valeur nette actuelle",
|
||||||
|
"asOf": "au {{date}}",
|
||||||
|
"noSnapshots": "Aucun snapshot pour l'instant. Créez-en un pour suivre l'évolution de votre bilan.",
|
||||||
|
"vsPrevious": "vs précédent",
|
||||||
|
"newSnapshot": "Nouveau snapshot",
|
||||||
|
"staleWarning": "Le dernier snapshot date de plus de {{days}} jours. Pensez à le mettre à jour pour suivre fidèlement l'évolution de votre bilan.",
|
||||||
|
"latestValue": "Dernière valeur",
|
||||||
|
"periodDelta": "Δ% sur la période",
|
||||||
|
"noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.",
|
||||||
|
"accountsTitle": "Comptes",
|
||||||
|
"detailAction": "Détail",
|
||||||
|
"detailComingSoon": "Disponible dans une prochaine version."
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"legend": "Période d'analyse",
|
||||||
|
"3M": "3 mois",
|
||||||
|
"6M": "6 mois",
|
||||||
|
"1A": "1 an",
|
||||||
|
"3A": "3 ans",
|
||||||
|
"all": "Tout"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"empty": "Aucun snapshot pour cette période.",
|
||||||
|
"modeLegend": "Mode d'affichage du graphique",
|
||||||
|
"totalSeriesLabel": "Total",
|
||||||
|
"mode": {
|
||||||
|
"line": "Ligne",
|
||||||
|
"stacked": "Empilé par catégorie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": "Bilan",
|
||||||
"accountsPage": {
|
"accountsPage": {
|
||||||
"title": "Comptes du bilan",
|
"title": "Comptes du bilan",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|
|
||||||
141
src/pages/BalancePage.tsx
Normal file
141
src/pages/BalancePage.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
// BalancePage — overview of net worth at `/balance`.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Composes:
|
||||||
|
// - BalanceOverviewCard (latest total + Δ% + staleness warning + new-snapshot CTA)
|
||||||
|
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
||||||
|
// - Chart-mode toggle (Line / Stacked-by-category)
|
||||||
|
// - BalanceEvolutionChart
|
||||||
|
// - BalanceAccountsTable (one row per active account with latest value + Δ%)
|
||||||
|
//
|
||||||
|
// All data flows through `useBalanceOverview` (scoped useReducer). Returns
|
||||||
|
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
|
||||||
|
// columns with a TODO comment.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Wallet } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useBalanceOverview,
|
||||||
|
type BalancePeriod,
|
||||||
|
type BalanceChartMode,
|
||||||
|
} from "../hooks/useBalanceOverview";
|
||||||
|
import { archiveBalanceAccount } from "../services/balance.service";
|
||||||
|
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||||
|
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||||
|
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
||||||
|
|
||||||
|
export default function BalancePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { state, setPeriod, setChartMode, reload } = useBalanceOverview();
|
||||||
|
|
||||||
|
// Build a category_key → translated label map from the accounts payload —
|
||||||
|
// the byCategory series is keyed by `key`, not by id, and the same
|
||||||
|
// taxonomy is already loaded with `accountsLatest` joins.
|
||||||
|
const categoryLabels = useMemo(() => {
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const a of state.accountsLatest) {
|
||||||
|
if (!m[a.category_key]) {
|
||||||
|
m[a.category_key] = t(a.category_i18n_key, {
|
||||||
|
defaultValue: a.category_key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [state.accountsLatest, t]);
|
||||||
|
|
||||||
|
const handleArchiveAccount = async (accountId: number) => {
|
||||||
|
try {
|
||||||
|
await archiveBalanceAccount(accountId);
|
||||||
|
await reload();
|
||||||
|
} catch {
|
||||||
|
// Reload swallows; the row simply stays. UX feedback can be added later.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={state.isLoading ? "opacity-60 pointer-events-none" : ""}>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Wallet size={24} className="text-[var(--primary)]" />
|
||||||
|
<h1 className="text-2xl font-bold">{t("balance.overview.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BalanceOverviewCard totals={state.evolutionTotals} />
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
{/* Period selector */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("balance.period.legend")}
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{PERIOD_OPTIONS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.period === p
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.period === p}
|
||||||
|
>
|
||||||
|
{t(`balance.period.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart mode toggle */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("balance.chart.modeLegend")}
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChartMode(mode)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.chartMode === mode
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.chartMode === mode}
|
||||||
|
>
|
||||||
|
{t(`balance.chart.mode.${mode}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BalanceEvolutionChart
|
||||||
|
mode={state.chartMode}
|
||||||
|
totals={state.evolutionTotals}
|
||||||
|
byCategory={state.evolutionByCategory}
|
||||||
|
categoryLabels={categoryLabels}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">
|
||||||
|
{t("balance.overview.accountsTitle")}
|
||||||
|
</h2>
|
||||||
|
<BalanceAccountsTable
|
||||||
|
accounts={state.accountsLatest}
|
||||||
|
periodAnchor={state.accountsPeriodAnchor}
|
||||||
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,10 @@ import {
|
||||||
validateLineKindInvariants,
|
validateLineKindInvariants,
|
||||||
PRICED_VALUE_TOLERANCE,
|
PRICED_VALUE_TOLERANCE,
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
|
getSnapshotTotalsByDate,
|
||||||
|
getSnapshotTotalsByCategoryAndDate,
|
||||||
|
getAccountsLatestSnapshot,
|
||||||
|
getAccountsPeriodAnchor,
|
||||||
} from "./balance.service";
|
} from "./balance.service";
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
const mockSelect = vi.fn();
|
||||||
|
|
@ -801,3 +805,172 @@ describe("upsertSnapshotLines — priced kind", () => {
|
||||||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
|
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Time-series aggregators (Issue #141 / Bilan #3)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getSnapshotTotalsByDate", () => {
|
||||||
|
it("returns an empty array on an empty DB", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
expect(await getSnapshotTotalsByDate()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates SUM(value) and orders ASC by snapshot_date", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ snapshot_date: "2026-01-31", total: 1000 },
|
||||||
|
{ snapshot_date: "2026-02-28", total: 1100 },
|
||||||
|
{ snapshot_date: "2026-03-31", total: 1250 },
|
||||||
|
]);
|
||||||
|
const out = await getSnapshotTotalsByDate();
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ snapshot_date: "2026-01-31", total: 1000 },
|
||||||
|
{ snapshot_date: "2026-02-28", total: 1100 },
|
||||||
|
{ snapshot_date: "2026-03-31", total: 1250 },
|
||||||
|
]);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("FROM balance_snapshots");
|
||||||
|
expect(sql).toContain("LEFT JOIN balance_snapshot_lines");
|
||||||
|
expect(sql).toContain("GROUP BY s.snapshot_date");
|
||||||
|
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
|
||||||
|
// Empty range → no WHERE clause + no params
|
||||||
|
expect(sql).not.toContain("WHERE");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an inclusive [from, to] date range filter", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getSnapshotTotalsByDate({ from: "2026-01-01", to: "2026-03-31" });
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("WHERE");
|
||||||
|
expect(sql).toContain("s.snapshot_date >=");
|
||||||
|
expect(sql).toContain("s.snapshot_date <=");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-03-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports an open-ended `from` only", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getSnapshotTotalsByDate({ from: "2026-01-01" });
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("s.snapshot_date >=");
|
||||||
|
expect(sql).not.toContain("s.snapshot_date <=");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSnapshotTotalsByCategoryAndDate", () => {
|
||||||
|
it("returns [] on empty DB", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
expect(await getSnapshotTotalsByCategoryAndDate()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buckets multiple category rows under the same snapshot_date", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ snapshot_date: "2026-01-31", category_key: "cash", total: 500 },
|
||||||
|
{ snapshot_date: "2026-01-31", category_key: "tfsa", total: 1500 },
|
||||||
|
{ snapshot_date: "2026-02-28", category_key: "cash", total: 700 },
|
||||||
|
{ snapshot_date: "2026-02-28", category_key: "tfsa", total: 1700 },
|
||||||
|
]);
|
||||||
|
const out = await getSnapshotTotalsByCategoryAndDate();
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
snapshot_date: "2026-01-31",
|
||||||
|
byCategory: { cash: 500, tfsa: 1500 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
snapshot_date: "2026-02-28",
|
||||||
|
byCategory: { cash: 700, tfsa: 1700 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("INNER JOIN balance_snapshot_lines");
|
||||||
|
expect(sql).toContain("INNER JOIN balance_accounts");
|
||||||
|
expect(sql).toContain("INNER JOIN balance_categories");
|
||||||
|
expect(sql).toContain("GROUP BY s.snapshot_date, c.key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies date range params when supplied", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getSnapshotTotalsByCategoryAndDate({
|
||||||
|
from: "2026-01-01",
|
||||||
|
to: "2026-12-31",
|
||||||
|
});
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("WHERE");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAccountsLatestSnapshot", () => {
|
||||||
|
it("returns [] when there are no active accounts", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
expect(await getAccountsLatestSnapshot()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns one row per active account joined with category metadata", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
account_name: "BMO chequing",
|
||||||
|
symbol: null,
|
||||||
|
balance_category_id: 10,
|
||||||
|
category_key: "cash",
|
||||||
|
category_i18n_key: "balance.category.cash",
|
||||||
|
category_kind: "simple",
|
||||||
|
latest_snapshot_date: "2026-03-31",
|
||||||
|
latest_value: 1234.56,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 2,
|
||||||
|
account_name: "Wealthsimple TFSA",
|
||||||
|
symbol: null,
|
||||||
|
balance_category_id: 11,
|
||||||
|
category_key: "tfsa",
|
||||||
|
category_i18n_key: "balance.category.tfsa",
|
||||||
|
category_kind: "simple",
|
||||||
|
latest_snapshot_date: null,
|
||||||
|
latest_value: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const out = await getAccountsLatestSnapshot();
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
expect(out[0].latest_value).toBe(1234.56);
|
||||||
|
expect(out[1].latest_value).toBeNull();
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
// Filter: only active, non-archived accounts.
|
||||||
|
expect(sql).toContain("a.is_active = 1");
|
||||||
|
expect(sql).toContain("a.archived_at IS NULL");
|
||||||
|
// LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
|
||||||
|
expect(sql).toContain("ORDER BY s.snapshot_date DESC");
|
||||||
|
expect(sql).toContain("LIMIT 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAccountsPeriodAnchor", () => {
|
||||||
|
it("queries with a from-only filter", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ account_id: 1, anchor_snapshot_date: "2026-01-31", anchor_value: 1000 },
|
||||||
|
]);
|
||||||
|
const rows = await getAccountsPeriodAnchor({ from: "2026-01-01" });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].anchor_value).toBe(1000);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("MIN(s.snapshot_date)");
|
||||||
|
expect(sql).toContain("GROUP BY l.account_id");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries with both from and to", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" });
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with an empty range (open-ended)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getAccountsPeriodAnchor({});
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
// No WHERE clause when neither bound is set.
|
||||||
|
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -731,3 +731,227 @@ export async function getPreviousSnapshot(
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Time-series aggregators (Issue #141 / Bilan #3) — used by BalancePage.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional [from, to] range filter expressed in ISO `YYYY-MM-DD` format.
|
||||||
|
* Both endpoints are inclusive. `from` and `to` may each be omitted to leave
|
||||||
|
* that side unbounded.
|
||||||
|
*/
|
||||||
|
export interface SnapshotDateRange {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregated total at a given snapshot date. */
|
||||||
|
export interface SnapshotTotalPoint {
|
||||||
|
snapshot_date: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDateRangeClause(
|
||||||
|
range: SnapshotDateRange | undefined,
|
||||||
|
baseAlias: string
|
||||||
|
): { clause: string; params: unknown[] } {
|
||||||
|
if (!range || (!range.from && !range.to)) {
|
||||||
|
return { clause: "", params: [] };
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (range.from) {
|
||||||
|
const from = normalizeSnapshotDate(range.from);
|
||||||
|
parts.push(`${baseAlias}.snapshot_date >= $${params.length + 1}`);
|
||||||
|
params.push(from);
|
||||||
|
}
|
||||||
|
if (range.to) {
|
||||||
|
const to = normalizeSnapshotDate(range.to);
|
||||||
|
parts.push(`${baseAlias}.snapshot_date <= $${params.length + 1}`);
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
return { clause: `WHERE ${parts.join(" AND ")}`, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the aggregated total value of every snapshot, sorted by date ASC.
|
||||||
|
* Used by the line variant of the evolution chart on `/balance`.
|
||||||
|
*
|
||||||
|
* The aggregation is `SUM(value) GROUP BY snapshot_date` — every account
|
||||||
|
* contributing to the snapshot is summed in. Snapshots with no lines
|
||||||
|
* collapse to a `total = 0` row (preserved so the chart shows continuity).
|
||||||
|
*/
|
||||||
|
export async function getSnapshotTotalsByDate(
|
||||||
|
range?: SnapshotDateRange
|
||||||
|
): Promise<SnapshotTotalPoint[]> {
|
||||||
|
const { clause, params } = buildDateRangeClause(range, "s");
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<SnapshotTotalPoint[]>(
|
||||||
|
`SELECT s.snapshot_date AS snapshot_date,
|
||||||
|
COALESCE(SUM(l.value), 0) AS total
|
||||||
|
FROM balance_snapshots s
|
||||||
|
LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
|
||||||
|
${clause}
|
||||||
|
GROUP BY s.snapshot_date
|
||||||
|
ORDER BY s.snapshot_date ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-snapshot breakdown by category. */
|
||||||
|
export interface SnapshotCategoryBreakdownPoint {
|
||||||
|
snapshot_date: string;
|
||||||
|
byCategory: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCategoryBreakdownRow {
|
||||||
|
snapshot_date: string;
|
||||||
|
category_key: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns per-snapshot totals broken down by `balance_categories.key`,
|
||||||
|
* sorted by date ASC. Used by the stacked-area variant of the evolution
|
||||||
|
* chart. Categories with no value at a given date are omitted from the
|
||||||
|
* `byCategory` map (chart consumers should treat absent keys as zero).
|
||||||
|
*
|
||||||
|
* Lines whose joined account points to no category are skipped — that
|
||||||
|
* shouldn't happen given FK RESTRICT but the JOIN is defensive.
|
||||||
|
*/
|
||||||
|
export async function getSnapshotTotalsByCategoryAndDate(
|
||||||
|
range?: SnapshotDateRange
|
||||||
|
): Promise<SnapshotCategoryBreakdownPoint[]> {
|
||||||
|
const { clause, params } = buildDateRangeClause(range, "s");
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.select<RawCategoryBreakdownRow[]>(
|
||||||
|
`SELECT s.snapshot_date AS snapshot_date,
|
||||||
|
c.key AS category_key,
|
||||||
|
COALESCE(SUM(l.value), 0) AS total
|
||||||
|
FROM balance_snapshots s
|
||||||
|
INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
|
||||||
|
INNER JOIN balance_accounts a ON a.id = l.account_id
|
||||||
|
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||||||
|
${clause}
|
||||||
|
GROUP BY s.snapshot_date, c.key
|
||||||
|
ORDER BY s.snapshot_date ASC, c.key ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
// Bucket rows by snapshot_date — many rows per date, one per category.
|
||||||
|
const out: SnapshotCategoryBreakdownPoint[] = [];
|
||||||
|
let current: SnapshotCategoryBreakdownPoint | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!current || current.snapshot_date !== r.snapshot_date) {
|
||||||
|
current = { snapshot_date: r.snapshot_date, byCategory: {} };
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current.byCategory[r.category_key] = r.total;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest-snapshot value per active account (Issue #141). */
|
||||||
|
export interface AccountLatestSnapshot {
|
||||||
|
account_id: number;
|
||||||
|
account_name: string;
|
||||||
|
symbol: string | null;
|
||||||
|
balance_category_id: number;
|
||||||
|
category_key: string;
|
||||||
|
category_i18n_key: string;
|
||||||
|
category_kind: BalanceCategoryKind;
|
||||||
|
/** Date of the snapshot whose value is reported, or null if no snapshot exists. */
|
||||||
|
latest_snapshot_date: string | null;
|
||||||
|
/** Value at that snapshot, or null if the account has no snapshot lines. */
|
||||||
|
latest_value: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns one row per active (non-archived) account with the value of its
|
||||||
|
* most-recent snapshot line. Accounts with no snapshot rows yet still
|
||||||
|
* appear, with `latest_value = null`. Used by the accounts table on
|
||||||
|
* `/balance` (#141) and as a building block for the period Δ% column.
|
||||||
|
*
|
||||||
|
* Implementation: a correlated subquery picks the line with the largest
|
||||||
|
* `s.snapshot_date` for each account — SQLite handles this fine on the
|
||||||
|
* indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
|
||||||
|
*/
|
||||||
|
export async function getAccountsLatestSnapshot(): Promise<
|
||||||
|
AccountLatestSnapshot[]
|
||||||
|
> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<AccountLatestSnapshot[]>(
|
||||||
|
`SELECT a.id AS account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
a.symbol AS symbol,
|
||||||
|
a.balance_category_id AS balance_category_id,
|
||||||
|
c.key AS category_key,
|
||||||
|
c.i18n_key AS category_i18n_key,
|
||||||
|
c.kind AS category_kind,
|
||||||
|
(SELECT s.snapshot_date
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
WHERE l.account_id = a.id
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1) AS latest_snapshot_date,
|
||||||
|
(SELECT l.value
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
WHERE l.account_id = a.id
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1) AS latest_value
|
||||||
|
FROM balance_accounts a
|
||||||
|
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||||||
|
WHERE a.is_active = 1 AND a.archived_at IS NULL
|
||||||
|
ORDER BY c.sort_order, a.name`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value at the earliest snapshot for each account whose
|
||||||
|
* `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the
|
||||||
|
* accounts table can compute a per-account Δ% over the selected period.
|
||||||
|
*
|
||||||
|
* Returns one row per account with a snapshot in range. Accounts without
|
||||||
|
* any snapshot in the period are omitted — callers default their Δ% to
|
||||||
|
* `null` (rendered as "—").
|
||||||
|
*/
|
||||||
|
export interface AccountPeriodAnchor {
|
||||||
|
account_id: number;
|
||||||
|
anchor_snapshot_date: string;
|
||||||
|
anchor_value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountsPeriodAnchor(
|
||||||
|
range: SnapshotDateRange
|
||||||
|
): Promise<AccountPeriodAnchor[]> {
|
||||||
|
// For each account, find the earliest snapshot_date >= range.from (and
|
||||||
|
// <= range.to when set), then read that line's value.
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (range.from) {
|
||||||
|
conditions.push(`s.snapshot_date >= $${params.length + 1}`);
|
||||||
|
params.push(normalizeSnapshotDate(range.from));
|
||||||
|
}
|
||||||
|
if (range.to) {
|
||||||
|
conditions.push(`s.snapshot_date <= $${params.length + 1}`);
|
||||||
|
params.push(normalizeSnapshotDate(range.to));
|
||||||
|
}
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<AccountPeriodAnchor[]>(
|
||||||
|
`SELECT l.account_id AS account_id,
|
||||||
|
MIN(s.snapshot_date) AS anchor_snapshot_date,
|
||||||
|
(SELECT l2.value
|
||||||
|
FROM balance_snapshot_lines l2
|
||||||
|
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
|
||||||
|
WHERE l2.account_id = l.account_id
|
||||||
|
AND s2.snapshot_date = MIN(s.snapshot_date)
|
||||||
|
LIMIT 1) AS anchor_value
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
${where}
|
||||||
|
GROUP BY l.account_id`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||||
icon: "BarChart3",
|
icon: "BarChart3",
|
||||||
labelKey: "nav.reports",
|
labelKey: "nav.reports",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "balance",
|
||||||
|
path: "/balance",
|
||||||
|
icon: "Wallet",
|
||||||
|
labelKey: "nav.balance",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "settings",
|
key: "settings",
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue