Compare commits

..

No commits in common. "main" and "v0.12.0" have entirely different histories.

52 changed files with 899 additions and 3931 deletions

View file

@ -2,7 +2,7 @@
name: release name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push) description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true user-invocable: true
updated: 2026-07-01 updated: 2026-04-19
--- ---
# /release — Release Simpl-Resultat # /release — Release Simpl-Resultat
@ -24,9 +24,9 @@ updated: 2026-07-01
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9) - `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective. - Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** : 3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
- `CHANGELOG.md` (EN) : header `## [Unreleased]` - `CHANGELOG.md` (EN)
- `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide) - `CHANGELOG.fr.md` (FR)
- Pattern de migration : transformer le header « non publié » de chaque fichier en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer la section « non publié » vide au-dessus** pour accueillir les prochaines entrees. Le header differe selon la langue. Ne pas deplacer le contenu — les sections sont laissees en place. - Pattern de migration : transformer `## [Unreleased]` en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer une section `## [Unreleased]` vide au-dessus** pour accueillir les prochaines entrees. Ne pas deplacer le contenu — les sections sont laissees en place.
4. Si changement d'architecture : mettre a jour `docs/architecture.md` 4. Si changement d'architecture : mettre a jour `docs/architecture.md`
5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs) 5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs)
6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) : 6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) :
@ -48,4 +48,3 @@ updated: 2026-07-01
## Changelog ## Changelog
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle) - 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.

View file

@ -2,29 +2,6 @@
## [Non publié] ## [Non publié]
## [0.13.0] - 2026-07-12
### Ajouté
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
- Rapports → Tendances : le panneau de filtre partagé apparaît désormais sur ce rapport, à côté du sélecteur de période. Cocher une ou plusieurs sources d'import restreint à la fois la vue mensuelle globale et le tableau de résultat par catégorie à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#275).
- Rapports → Comparaison et Budget : le panneau de filtre partagé apparaît désormais aussi sur ces deux rapports, à côté de leur contrôle de période existant (la comparaison garde son sélecteur de mois de référence, la grille budget garde son navigateur d'année — inchangés). Cocher une ou plusieurs sources d'import restreint le rapport de comparaison — y compris son onglet budget-vs-réel — et la colonne de référence de l'année précédente de la grille budget à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#276).
### Corrigé
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat couvre désormais **toutes** les catégories au lieu des 50 plus grosses, et ses lignes de Résultat sont exactes. Deux catégories différentes portant le même nom ne sont plus fusionnées, et un petit revenu ou transfert n'est plus comptabilisé comme une dépense — les revenus et le résultat net s'additionnent donc correctement (#264).
### Modifié
- Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262).
- Rapports → Comparaison (réel vs budget) : le tableau budget-vs-réel se lit désormais comme les autres rapports comparables — une **analyse de résultat**. Les catégories sont regroupées Revenus → Dépenses → Transferts (au lieu de Dépenses → Revenus → Transferts), et l'ancienne ligne « Total » plate est remplacée par une ligne **Résultat avant transferts** (affichée lorsque des transferts existent) et une ligne **Résultat net**, toutes deux colorées en vert pour un surplus, en rouge pour un déficit. Les montants de catégorie et de section sont inchangés (#277).
- Budget : la grille budget se lit désormais comme une analyse de résultat — les catégories sont regroupées **Revenus → Dépenses → Transferts** (auparavant dépenses en premier), et l'ancienne ligne « Total » brute est remplacée par deux lignes de résultat : **Résultat avant transferts** (revenus dépenses) et **Résultat net** (après transferts), calculées sur les colonnes année précédente, annuelle et mensuelles. Toutes les catégories restent affichées sur la grille — elle demeure une surface d'édition, sans ligne masquée ni repliable (#278).
- Tableau de bord (`/`) : la page d'accueil adopte désormais le modèle du rapport Cartes. Les cartes d'indicateurs (revenus, dépenses, résultat net, taux d'épargne) affichent leur variation vs le mois précédent et vs l'an dernier (sélecteur de mois de référence, par défaut le dernier mois complet) ; les catégories en hausse/en baisse et le respect du budget sont présentés de la même façon que sur Cartes. Une nouvelle tuile **valeur nette** affiche le dernier total du Bilan — une métrique différente de toutes les autres cartes de la page, donc masquée (jamais un « 0 $ » trompeur) tant qu'aucun compte de bilan n'a de snapshot enregistré, et jamais affectée par le filtre de comptes ci-dessous. Le camembert des dépenses est remplacé par des **barres classées** des catégories de dépense les plus importantes, et le filtre de comptes (sources d'import) — introduit sur Tendances/Comparaison/Budget — s'applique désormais aussi aux widgets transactionnels du tableau de bord (#279).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05
### Modifié ### Modifié

View file

@ -2,29 +2,6 @@
## [Unreleased] ## [Unreleased]
## [0.13.0] - 2026-07-12
### Added
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
- Reports → Trends: the shared filter panel now appears on this report, next to the period selector. Checking one or more import sources narrows both the global monthly view and the by-category result table to those sources; leaving none checked keeps showing every source, unchanged (#275).
- Reports → Compare & Budget: the shared filter panel now appears on both reports too, next to their existing period control (the compare report keeps its reference-month picker, the budget grid keeps its year navigator — unchanged). Checking one or more import sources narrows the compare report — including its budget-vs-actual tab — and the budget grid's previous-year reference column to those sources; leaving none checked keeps showing every source, unchanged (#276).
### Fixed
- Reports → Trends → by category (table view): the income-statement table now covers **every** category instead of only the 50 largest, and its Result lines are exact. Two different categories that happen to share a name are no longer merged together, and a smaller income or transfer category is no longer miscounted as an expense — so the revenues and the net result add up correctly (#264).
### Changed
- Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262).
- Reports → Compare (actual vs budget): the budget-vs-actual table now reads the same way as the other comparable reports — an **income statement**. Categories are grouped Income → Expenses → Transfers (instead of Expenses → Income → Transfers), and the old flat "Total" row is replaced by a **Result before transfers** line (shown when transfers exist) and a **Net result** line, both coloured green for a surplus and red for a deficit. Leaf and section figures are unchanged (#277).
- Budget: the budget grid now reads as an income statement — categories are grouped **Income → Expenses → Transfers** (previously expenses first), and the old plain "Total" row is replaced by two result lines: **Result before transfers** (income expenses) and **Net result** (after transfers), computed across the previous-year, annual, and monthly columns alike. Every category still shows on the grid — it stays an editable surface, with no rows hidden or collapsed (#278).
- Dashboard (`/`): the home page now matches the Cartes report's model. KPI cards for income, expenses, net result and savings rate show their change vs the previous month and vs last year (reference-month picker, defaulting to the last complete month); rising/falling categories and budget adherence are shown the same way as on Cartes. A new **net worth** tile shows the Balance sheet's latest total — a different metric from every other card here, so it stays hidden (never a misleading "$0") until at least one balance account has a recorded snapshot, and it is never affected by the account filter below. The expense-only pie chart is replaced by a **ranked bar chart** of top expense categories, and the account (import-source) filter — introduced on Trends/Compare/Budget — now also applies to the Dashboard's own transactional widgets (#279).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05
### Changed ### Changed

View file

@ -1,6 +1,6 @@
# STATE — Simpl'Résultat # STATE — Simpl'Résultat
> Derniere MAJ : 2026-07-12 (**#259 recadrée** après `/analyze` : son corps décrivait un flux inexistant du Bilan, le vrai sujet est le **mapping manuel des catégories custom** en migration de taxonomie → réécrite `status:ready`, **hors epic #260** ; l'epic #260 n'a donc plus de reliquat. Précédemment 2026-07-11 : **M2 « rapports-parite » #277-#279 shippée** sur `main` `a982f9e` — /pr-review ×3 [APPROVE #285/#286, REQUEST_CHANGES #287 → point I7 « filtrage Cartes » confirmé + fix `9ee5ad3`], merge local pile de 3 + union CHANGELOG, tip cumulé validé **811 vitest** + 98 Rust, réconciliation Forgejo complète. **30 commits non taggés depuis `v0.12.0`** — release en attente) > Derniere MAJ : 2026-07-05 (v0.11.0 shippée — milestone `overnight-2026-07-05-bilan-rapports-ux` 5/5 fermée : rapports hiérarchiques, netting transferts, landing Bilan tuiles, import CSV titres, migration cible éditable)
## Position actuelle ## Position actuelle
@ -12,21 +12,18 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
**v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe). **v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe).
**v0.12.0 shippée** (2026-07-05, tag `v0.12.0` → CI release #311) — recadrage de #243 (montants de transfert inclus dans le comparable) par Max en « analyse de résultat » : deux rapports comparables refondus en états de résultat (income statement). Le compare réel-vs-réel (#253) et le tableau de tendance par catégorie (#256) affichent le **revenu**, un **Résultat avant transferts** (revenus dépenses) et un **Résultat net** (après transferts), sections ordonnées revenu→dépense→transfert ; les rapports comparables ouvrent par défaut sur le **mois complet précédent** (#253). Modules purs extraits et testés (`compareResults.ts`, `overTimeResults.ts`) ; 2 blocages `/pr-review` corrigés (fuite du revenu dans les top-movers Cartes ; garde StrictMode sur le sync du mois de référence). Livré en 2 issues/PRs stackées #255→#257 (merge local + réconciliation Forgejo, branches supprimées), 701 vitest verts, aucune migration DB (v1→v16 inchangées), 20 tables / 24 index. Backlog ouvert au passage : #258 (db-locked `proposeStarterAccounts`) → fermé le 2026-07-07 (déjà corrigé v0.10.1, pas de nesting) ; #254 (collapse/expand) → livré le 2026-07-07 (PR #261) ; #259 (mapping manuel d'un compte standard sans similaire auto) et #260 (epic « rapports uniformes » — tous les rapports sur le modèle income-statement) ouverts.
## Decisions recentes ## Decisions recentes
- 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `<li>` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate``MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259)
- 2026-07-11 : **M2 « rapports-parite » (#277-#279) shippée** — dernière grosse tranche de l'epic #260 « rapports uniformes ». Run `/autopilot` (3 workers en worktree → PRs #285/#286/#287), reviewée via **3 `/pr-review` adversariales parallèles** (read-only, `git show` sans checkout) : **APPROVE #285** (grille budget income-first, #278) + **APPROVE #286** (BVA réel-vs-budget income-first, #277) + **REQUEST_CHANGES #287** (dashboard Cartes, #279). Blocage #287 = **le point I7 prédit au plan** confirmé réel : `getCartesSnapshot` propageait `accountIds` aux sous-rapports top-movers/budget mais **pas** aux séries `fetchMonthlyFlows` (KPIs/sparklines/overlay 12 mois) ni `fetchSeasonality` → sur le dashboard (1re page à exposer le filtre par-dessus ces séries) les KPI montraient des totaux non filtrés à côté de barres/tendance filtrées. **Fix** (`9ee5ad3`) : threader `accountIds?` dans les 2 fetchers (`source_id IN (...)` paramétré via `inPlaceholders`, index `$3`/`$4`) + câblage `getCartesSnapshot` ; le CHANGELOG #279 promettait déjà le filtrage → **code aligné sur la promesse, texte inchangé** ; +2 assertions cartes. **Merge local de la pile** (3 merges `--no-ff`, conflit additif CHANGELOG ×2 résolu par union #277/#278/#279 — i18n **non conflictuel** car #277/#278 réutilisent des clés existantes, seul #279 touche les locales ; STATE non conflictuel car branches ne l'ont pas commité) → tip cumulé validé (tsc + vite build + **811 vitest** + cargo check + **98 Rust**) → push `main` `a982f9e`. Réconciliation Forgejo : 3 issues auto-fermées via `Resolves #N`, 3 PRs fermées manuellement (merges locaux non détectés *merged*), milestone `overnight-2026-07-08-rapports-parite` **3/3 fermée**, branches supprimées, worktrees leftover nettoyés (gotcha recursion). Aucune migration DB (v1→v16), non taggé ([Unreleased]). Wrinkle process : le fork du skill `/pr-review` avait posté un APPROVE prématuré sur #287 (I7 gradé non bloquant) → superséé + commentaire stale supprimé par la passe adversariale indépendante (trace code + « 1re page à exposer le filtre » + CHANGELOG à honorer) ; valeur de la double-vérif indépendante. Suivi non bloquant : `getDashboardSummary` = code mort à retirer. Reste de #260 : #259 (mapping manuel compte sans similaire auto). (ref #260, #277-#279, PRs #285-#287)
- 2026-07-08 : **Suite #260 — M1 « filtres-fondation » mergée** (`main` `fe9ae01`), M2 prête. Planifiée via `/plan-overnight`+`/review-spec` (filtre **multi-comptes** `accountIds[]`/`IN(...)` tranché après analyse, révise le « sourceId singulier » initial). **M1 livrée** via `/autopilot` (5 workers séquentiels en worktree → PRs #280-#284 en **pile linéaire**) + `/pr-review` **APPROVE ×5** + **merge local fast-forward** : hook `useReportsPeriod` additif (+`accountIds` URL `sources` + type `ReportFilters`, #272), 7 services en `accountIds[]` via helper paramétré `sqlFilters.inPlaceholders` (#273), `<FilterPanel>` slot temporel + multi-select « sources d'import » (libellé distinct du Bilan, #274), adoption Tendances (#275) + Compare/Budget (#276 — incl. `CompareBudgetView` ; câblage budget via `getActualTotalsForYear`, le body citait à tort `getBudgetVsActualData`). Aucune migration DB. Build + **791 vitest** verts (728 + 63). NB : le « 4676 / 277 fichiers » vu en pré-merge était une **pollution de worktrees**`vitest` récursait dans les 5 worktrees leftover sous `.claude/worktrees/` (791 × ~6) ; nettoyés depuis, compteur réel = 791. Gotcha : nettoyer les worktrees (ou exclure `.claude/worktrees/`) avant de valider un tip. Réconciliation Forgejo complète (5 issues auto-fermées via `Resolves #N`, 5 PRs fermées, milestone fermée, branches supprimées ; worktrees nettoyés). **M2 `overnight-2026-07-08-rapports-parite` (#277-#279) prête**`/autopilot` (BVA + grille budget income-first + dashboard Cartes). Notes review pour M2 : séries `getCartesSnapshot` (`fetchMonthlyFlows`/`fetchSeasonality`) non filtrées (I7/#279) ; grille budget câblée via `getActualTotalsForYear`. (ref #260, #272-#276, PRs #280-#284)
- 2026-07-07 : **Tendances hiérarchiques livrées** (1re slice de l'epic #260 « rapports uniformes »). Préparée via `/plan-overnight` (13 décisions drainées ; `/review-spec` 3 experts → 3 critiques résolus par l'approche **sidecar** : garder le pivot par mois pour chart **+ dashboard** (`DashboardPage:142` = consommateur partagé oublié), ajouter un arbre **clé-par-id** pour le tableau — pivot clé-par-nom aurait faussé le Résultat net sur homonymes une fois le `LIMIT 50` retiré), puis exécutée **le même jour** via `/autopilot` (session interactive, ciblée par nom de milestone **hors fenêtre horaire** — cf. mémoire [[feedback-plan-execute-temporal-decoupling]]). 4 workers en worktree → issues **#262-#265**, `/pr-review` **APPROVE ×4**, merge local de la pile (#266 standalone + chaîne #267→#268→#269 ; CHANGELOG **auto-résolu par section**, vérifié) ; tip cumulé validé build + **728 vitest** → push `main` `fe87313`, réconciliation Forgejo (4 PRs fermées, 4 issues auto-fermées via `Resolves #N`, milestone `overnight-2026-07-08-tendances-hierarchiques` fermée, branches supprimées). Contenu : tendance par catégorie → tableau income-statement **hiérarchique + collapse replié-par-défaut** (défaut byCategory+table = fix « paie invisible »), `getCategoryOverTime` en **arbre id-keyed sidecar** via `buildLeafDrivenTree<T>` générique extrait de `buildCompareTree` (behavior-preserving), Résultat avant transferts interleavé. Aucune migration DB, non taggé ([Unreleased]). 2 polish non bloquants : perf dashboard (arbre construit non lu, #264), DRY `typeOf` dupliqué (#265). Reste de #260 (BVA-au-standard, budget, dashboard, filtres partagés) différé. (ref #260, #262-#265, PRs #266-#269)
- 2026-07-07 : #254 (collapse/expand des sous-catégories) livré + #258 (db-locked) fermé. **#254** : chevron par catégorie parente + « tout replier/déplier » sur les 2 rapports comparables hiérarchiques (`ComparePeriodTable` réel-vs-réel, `BudgetVsActualTable` réel-vs-budget) ; **repli par défaut** (contrainte remontée de #260), état persisté par rapport en localStorage. Repli purement visuel — sous-totaux/totaux/résultats calculés sur les lignes brutes, jamais les visibles (tracé en /pr-review). Module `collapsibleRows.ts` (pur) + hook `useCollapsibleGroups.ts` + 12 tests → **713 vitest**, aucune migration DB. `CategoryOverTimeTable` exclu (liste plate top-N, rien à replier) → relève de l'epic #260. Livré via sous-agent en worktree isolé + /pr-review APPROVE + merge API (single PR, pas de conflit i18n/CHANGELOG) → PR #261, #254 auto-fermée. **#258** fermé comme déjà corrigé en v0.10.1 (`8cb7e53`, présent v0.11.0/v0.12.0) : la piste « BEGIN imbriqué » du body était une mauvaise lecture — `withTransaction` (`db.ts`) n'émet aucun BEGIN, `proposeStarterAccountsInTransaction:720` est le seul (même pattern que `saveSnapshotAtomic`/`upsertSnapshotLines` qui ne deadlockent jamais) ; smell latent noté (garde `inTransaction` = booléen global, pas un compteur → requête simple concurrente pendant une transaction bypasse le verrou). Prochaine étape : /plan-overnight de l'epic #260. (ref #254/#258, PR #261)
- 2026-07-05 : **v0.12.0 shippée** (tag `v0.12.0` → CI release #311). Compare réel-vs-réel (#253) + tableau de tendance par catégorie (#256) refondus en **analyse de résultat** : revenu affiché, **Résultat avant transferts** (revenus dépenses) + **Résultat net** (après transferts), sections revenu→dépense→transfert ; rapports comparables par défaut au **mois complet précédent** (#253). Origine : #243 (montants de transfert inclus dans le comparable) recadré par Max en « voir si je suis over/under ». Livré en 2 issues/PRs stackées #255→#257 (merge local + réconciliation Forgejo, PRs fermées, branches supprimées). Modules purs `compareResults.ts`/`overTimeResults.ts` testés ; 2 blocages `/pr-review` corrigés (fuite du revenu dans les top-movers Cartes via `COMPARE_DELTA_SQL` élargi income+transfer → filtre expense-only + régression ; garde StrictMode value-change sur le sync mois-réf, `syncReferenceOnPeriodChange` pur). 701 vitest verts, aucune migration DB. Ouvert au passage : #258 (db-locked `proposeStarterAccounts`, probable BEGIN imbriqué dans `withTransaction`), #259 (mapping manuel compte sans similaire), #254 (collapse/expand). Suivi différé : aligner le placement « Résultat avant transferts » de `CategoryOverTimeTable` (bas) sur le compare (interleavé). (ref #253/#256, PRs #255/#257)
- 2026-07-05 : Run `/autopilot` sur `overnight-2026-07-05-bilan-rapports-ux` (préparée la veille via `/analyze` #243-#246 + split #247) → 5 issues livrées, PRs #248-#252 toutes `/pr-review` APPROVE. **Merge local de la pile** (l'API merge échoue sur les conflits i18n/CHANGELOG garantis — 4 fichiers partagés `fr.json`/`en.json`/`CHANGELOG.md`/`CHANGELOG.fr.md`, voir mémoire [[reference-shared-conflict-files]]) ; réconciliation Forgejo (PRs fermées manuellement, issues auto-fermées via `Resolves #N`, milestone fermée) ; release **v0.11.0** taggée. Validation locale du tip cumulé : build + **679 vitest** + cargo verts (`check.yml` ne tourne ni sur push `main` ni sur les bases non-`main` type #249). Polish #252 post-review : picker migration feuilles-seulement + helper `comboboxCategoriesForTarget` testé. (ref #243-#247, PRs #248-#252) - 2026-07-05 : Run `/autopilot` sur `overnight-2026-07-05-bilan-rapports-ux` (préparée la veille via `/analyze` #243-#246 + split #247) → 5 issues livrées, PRs #248-#252 toutes `/pr-review` APPROVE. **Merge local de la pile** (l'API merge échoue sur les conflits i18n/CHANGELOG garantis — 4 fichiers partagés `fr.json`/`en.json`/`CHANGELOG.md`/`CHANGELOG.fr.md`, voir mémoire [[reference-shared-conflict-files]]) ; réconciliation Forgejo (PRs fermées manuellement, issues auto-fermées via `Resolves #N`, milestone fermée) ; release **v0.11.0** taggée. Validation locale du tip cumulé : build + **679 vitest** + cargo verts (`check.yml` ne tourne ni sur push `main` ni sur les bases non-`main` type #249). Polish #252 post-review : picker migration feuilles-seulement + helper `comboboxCategoriesForTarget` testé. (ref #243-#247, PRs #248-#252)
- 2026-07-04 : Override `@babel/core` mergé (issue #241, PR #242 — review /pr-review APPROVE + CI verte rust/frontend) — clôt le reliquat `@babel/core` low du sprint `deps-security-2026-07`. GHSA-4x5r-pxfx-6jf8 (arbitrary file read via `sourceMappingURL`, CVSS 3.2, dev/build-only via `@vitejs/plugin-react`) : `npm audit fix` inopérant (version pinnée par le parent → empile une copie sans résoudre), fix = `overrides` scopé `{"@vitejs/plugin-react":{"@babel/core":"^7.29.7"}}` → patch 7.x non-breaking (7.29.0→7.29.7), arbre 109→109 (pas d'explosion), `npm audit` 1→0. Build + 635 vitest verts. Détecté via /analyse-vulnerabilite — rapport VPS `--fresh` inaccessible (auth navigateur Tailscale SSH en attente, jamais reçu le lien) → `npm audit` live utilisé ; rapport local périmé (2026-05-06) portait 1 faux positif `secrets` (« Generic Token Assignment » sur `balance.service.ts`, contenu disparu depuis la réécriture Bilan). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #241, PR #242) - 2026-07-04 : Override `@babel/core` mergé (issue #241, PR #242 — review /pr-review APPROVE + CI verte rust/frontend) — clôt le reliquat `@babel/core` low du sprint `deps-security-2026-07`. GHSA-4x5r-pxfx-6jf8 (arbitrary file read via `sourceMappingURL`, CVSS 3.2, dev/build-only via `@vitejs/plugin-react`) : `npm audit fix` inopérant (version pinnée par le parent → empile une copie sans résoudre), fix = `overrides` scopé `{"@vitejs/plugin-react":{"@babel/core":"^7.29.7"}}` → patch 7.x non-breaking (7.29.0→7.29.7), arbre 109→109 (pas d'explosion), `npm audit` 1→0. Build + 635 vitest verts. Détecté via /analyse-vulnerabilite — rapport VPS `--fresh` inaccessible (auth navigateur Tailscale SSH en attente, jamais reçu le lien) → `npm audit` live utilisé ; rapport local périmé (2026-05-06) portait 1 faux positif `secrets` (« Generic Token Assignment » sur `balance.service.ts`, contenu disparu depuis la réécriture Bilan). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #241, PR #242)
- 2026-07-01 : Sprint `deps-security-2026-07` livré (4/4, milestone fermée) via /sprint — 4 vulns Defenseur (2026-06-30) remédiées en 2 PRs séquentielles (fichier partagé `package.json` → pas de worktrees parallèles). PR A #239 : `react-router-dom` 7.13→7.18.1 ferme #235 (racine, 7 advisories dont turbo-stream RCE) + #238 (0 CVE propre, héritage transitif — 1 bump du parent suffit, `npm audit viaParents`). PR B #240 : `vite` 6.4.2→6.4.3 (#236) + `vitest` 4.0.18→4.1.9 (#237), restés en 6.x/4.x (majors 8.x/latest écartés), 635 vitest verts (bump vitest 4.0→4.1 sans régression). `npm audit` 5→1 ; reste `@babel/core` low volontairement hors scope (`npm audit fix` ajoute 77 paquets sans résoudre, pinné par parent → override scopé dédié à faire). Surface réelle faible (app Tauri desktop, pas de serveur SSR/RSC). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #235-#238, PRs #239/#240) - 2026-07-01 : Sprint `deps-security-2026-07` livré (4/4, milestone fermée) via /sprint — 4 vulns Defenseur (2026-06-30) remédiées en 2 PRs séquentielles (fichier partagé `package.json` → pas de worktrees parallèles). PR A #239 : `react-router-dom` 7.13→7.18.1 ferme #235 (racine, 7 advisories dont turbo-stream RCE) + #238 (0 CVE propre, héritage transitif — 1 bump du parent suffit, `npm audit viaParents`). PR B #240 : `vite` 6.4.2→6.4.3 (#236) + `vitest` 4.0.18→4.1.9 (#237), restés en 6.x/4.x (majors 8.x/latest écartés), 635 vitest verts (bump vitest 4.0→4.1 sans régression). `npm audit` 5→1 ; reste `@babel/core` low volontairement hors scope (`npm audit fix` ajoute 77 paquets sans résoudre, pinné par parent → override scopé dédié à faire). Surface réelle faible (app Tauri desktop, pas de serveur SSR/RSC). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #235-#238, PRs #239/#240)
- 2026-06-30 : hotfix v0.10.1 shippé + déployé (PR #230) — corrige le « database is locked » 0.10.0 (après abandon d'un snapshot en cours). Cause racine : `tauri-plugin-sql` charge SQLite via un pool sqlx multi-connexions (max 10) sans primitive de transaction JS → `db.execute("BEGIN")`/`"COMMIT"` en appels séparés pouvaient frapper des connexions différentes → transaction d'écriture zombie. Fix : sérialisation FIFO de tout l'accès DB dans `db.ts` ; `withTransaction()` tient le verrou sur tout le `BEGIN..COMMIT` ; appliqué aux 5 sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) par extraction de helper en place ; `db.test.ts` (régression). + console de log : `getLogs` retourne un snapshot immuable (live-update) + API `logInfo/logWarn/logError`. 635 vitest, CI vert. Voir mémoire [[reference-tauri-sql-transactions]] (ref #230) - 2026-06-30 : hotfix v0.10.1 shippé + déployé (PR #230) — corrige le « database is locked » 0.10.0 (après abandon d'un snapshot en cours). Cause racine : `tauri-plugin-sql` charge SQLite via un pool sqlx multi-connexions (max 10) sans primitive de transaction JS → `db.execute("BEGIN")`/`"COMMIT"` en appels séparés pouvaient frapper des connexions différentes → transaction d'écriture zombie. Fix : sérialisation FIFO de tout l'accès DB dans `db.ts` ; `withTransaction()` tient le verrou sur tout le `BEGIN..COMMIT` ; appliqué aux 5 sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) par extraction de helper en place ; `db.test.ts` (régression). + console de log : `getLogs` retourne un snapshot immuable (live-update) + API `logInfo/logWarn/logError`. 635 vitest, CI vert. Voir mémoire [[reference-tauri-sql-transactions]] (ref #230)
- 2026-06-29 : #228 mergé (PR #229) — garde d'abort migration v16 scopée aux comptes convertibles (`JOIN balance_categories` + `c.asset_type IS NOT NULL` dans Migration v16 + V16_SQL + V16_CORRUPT). Un compte simple portant un symbole résiduel (recat priced→simple) avec lignes qty-NULL ne fait plus aborter v16 → l'app redémarre. Test régression `migration_v16_leaves_simple_account_with_residual_symbol_intact`. CI vert (#229 → main, contrairement à la pile #219-#227). Milestone overnight-2026-06-05-bilan-detail-titres complète 10/10, fermée. Reste : tag release 0.10.0 (ref #228) - 2026-06-29 : #228 mergé (PR #229) — garde d'abort migration v16 scopée aux comptes convertibles (`JOIN balance_categories` + `c.asset_type IS NOT NULL` dans Migration v16 + V16_SQL + V16_CORRUPT). Un compte simple portant un symbole résiduel (recat priced→simple) avec lignes qty-NULL ne fait plus aborter v16 → l'app redémarre. Test régression `migration_v16_leaves_simple_account_with_residual_symbol_intact`. CI vert (#229 → main, contrairement à la pile #219-#227). Milestone overnight-2026-06-05-bilan-detail-titres complète 10/10, fermée. Reste : tag release 0.10.0 (ref #228)
- 2026-06-09 : Etape 2 bilan « detail par titre » livree — pile de 9 PRs stackees #219-#227 mergee bottom-up (merge manuel via API Forgejo, pas /fix-issue ; retarget auto base->main entre chaque), milestone overnight-2026-06-05-bilan-detail-titres 9/10, issues #210-#218 fermees. `balance_securities` + `balance_snapshot_holdings`, `account.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion comptes cotes -> detailles 1-position, service securities transactionnel, reducer holdings + dispatch account.kind, UI SecurityPicker + wizard date-pivot, drill-down + gain latent, ADR 0015 Accepted. Validation locale pre-merge (build + 627 vitest + cargo check + 97 Rust) car check.yml ne couvre pas les PRs vers bases non-main. Reste #228 (fix-forward garde v16) avant tag release (ref #210-#218, #219-#227)
- 2026-06-01 : Etape 1 bilan « axe vehicule » livree via /sprint (4 issues, PRs #206-#209 mergees, milestone fermee). `vehicle_type` nullable = enveloppe fiscale sur le compte ; categorie = pure classe d'actif (5) ; migrations additives v12/v13 (v1-v11 intactes, checksum SHA-384) ; reclass ex-CELI/REER -> Autres ; `custom_label` (fix bug I renommage) ; toggle graphique classe/enveloppe + rendements repliables persistes. ADR 0014 Accepted, ADR 0012 Rejected. Etape 2 (detail par titre) reportee (ref #202-#205, #206-#209)
- 2026-05-31 : Audit bilan (revue CPA + UX) -> `docs/audit-bilan-2026-05.md` ; Etape 0 quick wins mergee (#201) : terminologie categorie->type, symbole optionnel pour comptes cotes, date de snapshot deplacable (ref #198/#199/#200). Racine identifiee : modele plat fusionne vehicule fiscal x classe d'actif ; chantier structurel a specifier, ADR 0012 a superseder
- 2026-05-10 : Release v0.9.1 + note changelog maximus-api activation post-0.9.0 (ref #197)
- 2026-05-09 : ADR 0013 — stocks provider evaluation, AlphaVantage retenu comme bascule cible (ref #196)
## Blockers actifs ## Blockers actifs

View file

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

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.13.0", "version": "0.12.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.13.0", "version": "0.12.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View file

@ -1,7 +1,7 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.13.0", "version": "0.12.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {

2
src-tauri/Cargo.lock generated
View file

@ -4509,7 +4509,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "simpl-result" name = "simpl-result"
version = "0.13.0" version = "0.12.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.13.0" version = "0.12.0"
description = "Personal finance management app" description = "Personal finance management app"
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["you"] authors = ["you"]

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat", "productName": "Simpl Resultat",
"version": "0.13.0", "version": "0.12.0",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react"; import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -128,9 +127,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
grouped[key].push(row); grouped[key].push(row);
} }
// Income-statement reading order: revenue first, then expenses, then const typeOrder = ["expense", "income", "transfer"] as const;
// transfers (Issue #278) — matches the compare/trend reports' ordering.
const typeOrder = ["income", "expense", "transfer"] as const;
const typeLabelKeys: Record<string, string> = { const typeLabelKeys: Record<string, string> = {
expense: "budget.expenses", expense: "budget.expenses",
income: "budget.income", income: "budget.income",
@ -142,12 +139,19 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
transfer: "budget.totalTransfers", transfer: "budget.totalTransfers",
}; };
// Income-statement roll-up (Issue #278): Résultat avant transferts / net, // Column totals with sign convention (only count leaf rows to avoid double-counting parents)
// computed on the budgeted + previous-year-actual leaves. Mathematically const monthTotals: number[] = Array(12).fill(0);
// equivalent to the old plain grand-total (income expense + transfer over let annualTotal = 0;
// every leaf), now surfaced as the two labeled result rows below instead of let prevYearTotal = 0;
// a single unlabeled "Total". for (const row of rows) {
const results = computeBudgetResults(rows); if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
const sign = signFor(row.category_type);
for (let m = 0; m < 12; m++) {
monthTotals[m] += row.months[m] * sign;
}
annualTotal += row.annual * sign;
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
const totalCols = 15; // category + prev year + annual + 12 months const totalCols = 15; // category + prev year + annual + 12 months
@ -291,77 +295,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
); );
}; };
// One type's section: header, its (reorderable) rows, and a leaf-summed
// subtotal row. Extracted so it can be called for income/expense, then
// again for transfer once the Résultat rows are interleaved between them.
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
const group = grouped[type];
if (!group || group.length === 0) return null;
const sign = signFor(type);
const leaves = group.filter((r) => !r.is_parent);
const sectionMonthTotals: number[] = Array(12).fill(0);
let sectionAnnualTotal = 0;
let sectionPrevYearTotal = 0;
for (const row of leaves) {
for (let m = 0; m < 12; m++) {
sectionMonthTotals[m] += row.months[m] * sign;
}
sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
return (
<Fragment key={type}>
<tr>
<td
colSpan={totalCols}
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
>
{t(typeLabelKeys[type])}
</td>
</tr>
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])}
</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
{sectionMonthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
{formatSigned(total)}
</td>
))}
</tr>
</Fragment>
);
};
// A Résultat row (avant-transferts subtotal, or the net bottom line).
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
// the softer variant mirrors the per-type section-subtotal row above.
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
const rowClass = strong
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
: "bg-[var(--muted)]/40 border-b border-[var(--border)]";
const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40";
const cellWeight = strong ? "" : "font-semibold";
const pad = strong ? "py-3" : "py-2.5";
return (
<tr key={labelKey} className={rowClass}>
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
{formatSigned(tot.previousYearTotal)}
</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
{tot.months.map((val, mIdx) => (
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
{formatSigned(val)}
</td>
))}
</tr>
);
};
return ( return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"> <div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
@ -394,16 +327,58 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{renderTypeSection("income")} {typeOrder.map((type) => {
{renderTypeSection("expense")} const group = grouped[type];
{/* Operating result (revenues expenses), interleaved before the if (!group || group.length === 0) return null;
transfers section only when transfers exist otherwise it const sign = signFor(type);
equals the net result below and would just be noise. */} const leaves = group.filter((r) => !r.is_parent);
{results.hasTransfers && const sectionMonthTotals: number[] = Array(12).fill(0);
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)} let sectionAnnualTotal = 0;
{renderTypeSection("transfer")} let sectionPrevYearTotal = 0;
{/* Bottom line: result after netting transfers. */} for (const row of leaves) {
{renderResultRow("reports.compare.resultNet", results.resultNet, true)} for (let m = 0; m < 12; m++) {
sectionMonthTotals[m] += row.months[m] * sign;
}
sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
return (
<Fragment key={type}>
<tr>
<td
colSpan={totalCols}
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
>
{t(typeLabelKeys[type])}
</td>
</tr>
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])}
</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
{sectionMonthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
{formatSigned(total)}
</td>
))}
</tr>
</Fragment>
);
})}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-3 px-2 text-right text-sm">
{formatSigned(total)}
</td>
))}
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -1,94 +0,0 @@
import { describe, it, expect } from "vitest";
import type { BudgetYearRow } from "../../shared/types";
import { computeBudgetResults, sumLeavesForType, signForBudgetType } from "./budgetTableResults";
// Minimal leaf factory. `months` arrives as the positive-magnitude budgeted
// figure BudgetTable itself flips by type via `signFor` — same convention
// `sumLeavesForType` re-implements. `annual` always equals `sum(months)`
// (useBudget derives it that way; see useBudget.ts's `buildMonths`).
function leaf(
type: BudgetYearRow["category_type"],
months: number[],
previousYearTotal = 0,
overrides: Partial<BudgetYearRow> = {},
): BudgetYearRow {
return {
category_id: 1,
category_name: type,
category_color: "#000",
category_type: type,
parent_id: null,
is_parent: false,
depth: 0,
months,
annual: months.reduce((s, v) => s + v, 0),
previousYearTotal,
...overrides,
};
}
const FLAT12 = (v: number) => Array(12).fill(v) as number[];
describe("computeBudgetResults — income-statement roll-up (Issue #278)", () => {
it("resultBefore = budgeted income expense; resultNet adds transfers", () => {
const r = computeBudgetResults([
leaf("income", FLAT12(400)), // annual 4800
leaf("expense", FLAT12(300)), // annual 3600, stored positive, flipped to -300/mo
leaf("transfer", FLAT12(0)),
]);
expect(r.resultBefore.months[0]).toBe(100); // 400 300
expect(r.resultBefore.annual).toBe(1200); // 4800 3600
expect(r.resultNet.annual).toBe(1200); // balanced (0) transfer → unchanged
expect(r.hasTransfers).toBe(true);
});
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
const r = computeBudgetResults([
leaf("income", FLAT12(1000)),
leaf("expense", FLAT12(700)),
leaf("transfer", FLAT12(50)), // uncategorized single leg, signed pass-through
]);
expect(r.resultBefore.annual).toBe(3600); // (1000 700) × 12
expect(r.resultNet.annual).toBe(4200); // resultBefore + transfer(50×12)
});
it("a deficit yields a negative net result and no transfer line when absent", () => {
const r = computeBudgetResults([leaf("income", FLAT12(200)), leaf("expense", FLAT12(250))]);
expect(r.resultNet.months[0]).toBe(-50);
expect(r.hasTransfers).toBe(false);
});
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
const parent = leaf("expense", FLAT12(999), 0, { is_parent: true });
const r = computeBudgetResults([parent, leaf("expense", FLAT12(100))]);
expect(r.expense.annual).toBe(-1200); // only the leaf's 100 × 12 (flipped), not 999
});
it("carries the previous-year actual through unsigned (already signed in the DB)", () => {
// Actual totals arrive pre-signed: expense negative, income positive.
const r = computeBudgetResults([
leaf("income", FLAT12(100), 1150),
leaf("expense", FLAT12(60), -650),
]);
expect(r.resultBefore.previousYearTotal).toBe(500); // 1150 + (650), no re-sign
});
it("sumLeavesForType filters by type and excludes parents", () => {
const t = sumLeavesForType(
[
leaf("expense", FLAT12(50)),
leaf("expense", FLAT12(999), 0, { is_parent: true }),
leaf("income", FLAT12(10)),
],
"expense",
);
expect(t.annual).toBe(-600); // 50 × 12, flipped negative
expect(t.months[0]).toBe(-50);
});
it("signForBudgetType flips expense only", () => {
expect(signForBudgetType("expense")).toBe(-1);
expect(signForBudgetType("income")).toBe(1);
expect(signForBudgetType("transfer")).toBe(1);
});
});

View file

@ -1,94 +0,0 @@
import type { BudgetYearRow } from "../../shared/types";
export type BudgetSectionType = "income" | "expense" | "transfer";
/**
* Sign multiplier applied to budgeted (months/annual) magnitudes. Budgeted
* amounts are entered/stored as positive magnitudes regardless of type and
* flipped for display by category type mirrors `BudgetTable`'s `signFor`.
*/
export function signForBudgetType(type: BudgetSectionType): 1 | -1 {
return type === "expense" ? -1 : 1;
}
/**
* Aggregate of the budget grid's 3 comparable column groups, already signed:
* 12 monthly (budgeted) totals, the annual (budgeted) total, and the
* previous-year (actual) total.
*/
export interface BudgetTotals {
/** index 0-11 = Jan-Dec, signed budgeted total */
months: number[];
/** signed budgeted annual total */
annual: number;
/** signed actual total from the previous year (already signed in the DB) */
previousYearTotal: number;
}
function zeroTotals(): BudgetTotals {
return { months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0 };
}
/**
* Sum every non-subtotal (leaf) row of a single type into one `BudgetTotals`.
*
* The type's sign is applied to the budgeted figures (`months`/`annual`),
* matching how `BudgetTable` displays them. `previousYearTotal` is NOT
* re-signed: it comes from `getActualTotalsForYear` (a plain signed
* `SUM(amount)` over the transactions table), already negative for expenses
* and ~0-net for balanced transfers the same convention `compareResults.ts`
* relies on for its own previous-period figures.
*/
export function sumLeavesForType(rows: BudgetYearRow[], type: BudgetSectionType): BudgetTotals {
const sign = signForBudgetType(type);
const totals = zeroTotals();
for (const row of rows) {
if (row.is_parent || row.category_type !== type) continue;
for (let m = 0; m < 12; m++) totals.months[m] += row.months[m] * sign;
totals.annual += row.annual * sign;
totals.previousYearTotal += row.previousYearTotal;
}
return totals;
}
/** Component-wise sum of two BudgetTotals (both already signed). */
function combine(a: BudgetTotals, b: BudgetTotals): BudgetTotals {
return {
months: a.months.map((v, i) => v + b.months[i]),
annual: a.annual + b.annual,
previousYearTotal: a.previousYearTotal + b.previousYearTotal,
};
}
export interface BudgetResults {
income: BudgetTotals;
expense: BudgetTotals;
transfer: BudgetTotals;
/** Budgeted/actual revenues expenses: the operating result, before transfers. */
resultBefore: BudgetTotals;
/** resultBefore + transfers: the bottom-line total. */
resultNet: BudgetTotals;
hasTransfers: boolean;
}
/**
* Income-statement roll-up for the budget grid (Issue #278), mirroring
* `compareResults.ts`'s shape (see that module's doc comment for the sign
* convention this relies on).
*
* Operates on the flat `BudgetYearRow[]` the grid already renders (produced
* by `useBudget`) LEAVES only (`is_parent === false`), so parent subtotal
* rows are never double-counted. All three column groups (previous-year
* actual, budgeted annual, budgeted monthly) are folded into the same result
* rows so "Résultat avant transferts" / "Résultat net" read consistently
* across every column of the grid.
*/
export function computeBudgetResults(rows: BudgetYearRow[]): BudgetResults {
const income = sumLeavesForType(rows, "income");
const expense = sumLeavesForType(rows, "expense");
const transfer = sumLeavesForType(rows, "transfer");
const resultBefore = combine(income, expense);
const resultNet = combine(resultBefore, transfer);
const hasTransfers = rows.some((r) => !r.is_parent && r.category_type === "transfer");
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
}

View file

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

View file

@ -1,11 +1,8 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import { ArrowUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types"; import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { new Intl.NumberFormat("en-CA", {
@ -29,17 +26,6 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
// Collapse groups keyed by category id; depth mirrors the render logic below
// (a missing depth is derived from parent_id) so hidden rows are exactly a
// group's indented descendants.
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
keyOf: (row) => String(row.category_id),
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
isParent: (row) => row.is_parent,
};
const BVA_EXPANDED_STORAGE_KEY = "reports-bva-expanded";
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
@ -55,12 +41,6 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
}); });
}; };
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
const groups = useCollapsibleGroups<BudgetVsActualRow>(
BVA_EXPANDED_STORAGE_KEY,
BVA_COLLAPSE_ACCESSORS,
);
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -69,8 +49,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
); );
} }
// Group rows into contiguous type sections (the service already type-sorts: // Group rows by type for section headers
// income → expense → transfer, Issue #277). type SectionType = "expense" | "income" | "transfer";
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = []; const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
const typeLabels: Record<SectionType, string> = { const typeLabels: Record<SectionType, string> = {
expense: t("budget.expenses"), expense: t("budget.expenses"),
@ -92,176 +72,25 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
sections[sections.length - 1].rows.push(row); sections[sections.length - 1].rows.push(row);
} }
// Income-statement result lines (Issue #277): income + expense (expense // Grand totals (leaf rows only)
// already arrives signed negative in BudgetVsActualRow — see const leaves = data.filter((r) => !r.is_parent);
// budgetVsActualResults.ts), then the net after adding transfers. Replaces const totals = leaves.reduce(
// the old flat grand total row. (acc, r) => ({
const results = computeResults(data); monthActual: acc.monthActual + r.monthActual,
const nonTransferSections = sections.filter((s) => s.type !== "transfer"); monthBudget: acc.monthBudget + r.monthBudget,
const transferSection = sections.find((s) => s.type === "transfer"); monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => { ytdBudget: acc.ytdBudget + r.ytdBudget,
const sectionTotals = sumLeaves(section.rows); ytdVariation: acc.ytdVariation + r.ytdVariation,
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget); }),
const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget); { monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
return ( );
<Fragment key={section.type}> const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
<tr className="bg-[var(--muted)]"> const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
{section.label}
</td>
</tr>
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
}`}
>
<td className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}>
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</button>
) : (
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
)}
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.monthActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{cadFormatter(row.monthVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{pctFormatter(row.monthVariationPct)}
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.ytdActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{cadFormatter(row.ytdVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{pctFormatter(row.ytdVariationPct)}
</td>
</tr>
);
})}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{cadFormatter(sectionTotals.monthVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{pctFormatter(sectionMonthPct)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.ytdActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{cadFormatter(sectionTotals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{pctFormatter(sectionYtdPct)}
</td>
</tr>
</Fragment>
);
};
// A result line (before-transfers subtotal or the net total). Every cell is
// coloured by its own sign — a surplus is green, a deficit red — mirroring
// ComparePeriodTable.renderResultRow (unlike normal rows, which only colour
// the variation/pct columns).
const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => {
const rowBg = strong
? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"
: "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]";
const rowClass = strong
? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}`
: `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`;
const cell = "text-right px-3 py-3";
const monthPct = pct(tot.monthVariation, tot.monthBudget);
const ytdPct = pct(tot.ytdVariation, tot.ytdBudget);
return (
<tr key={labelKey} className={rowClass}>
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.monthActual)}`}>
{cadFormatter(tot.monthActual)}
</td>
<td className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
{cadFormatter(tot.monthVariation)}
</td>
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>{pctFormatter(monthPct)}</td>
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.ytdActual)}`}>
{cadFormatter(tot.ytdActual)}
</td>
<td className={`${cell} ${variationColor(tot.ytdBudget)}`}>{cadFormatter(tot.ytdBudget)}</td>
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{cadFormatter(tot.ytdVariation)}</td>
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{pctFormatter(ytdPct)}</td>
</tr>
);
};
const hasGroups = groups.groupCount(data) > 0;
const allExpanded = groups.allExpanded(data);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button <button
onClick={toggleSubtotals} onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
@ -312,15 +141,130 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{nonTransferSections.map(renderSection)} {sections.map((section) => {
{/* Operating result (income + expense), shown before the transfers const sectionLeaves = section.rows.filter((r) => !r.is_parent);
section only when transfers exist otherwise it equals the net const sectionTotals = sectionLeaves.reduce(
total below and would just be noise (mirrors ComparePeriodTable). */} (acc, r) => ({
{results.hasTransfers && monthActual: acc.monthActual + r.monthActual,
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)} monthBudget: acc.monthBudget + r.monthBudget,
{transferSection && renderSection(transferSection)} monthVariation: acc.monthVariation + r.monthVariation,
{/* Bottom line: result after adding transfers. */} ytdActual: acc.ytdActual + r.ytdActual,
{renderResultRow("reports.compare.resultNet", results.resultNet, true)} ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
return (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
{section.label}
</td>
</tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
}`}
>
<td className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}>
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.monthActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{cadFormatter(row.monthVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{pctFormatter(row.monthVariationPct)}
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.ytdActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{cadFormatter(row.ytdVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{pctFormatter(row.ytdVariationPct)}
</td>
</tr>
);
})}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{cadFormatter(sectionTotals.monthVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{pctFormatter(sectionMonthPct)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.ytdActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{cadFormatter(sectionTotals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{pctFormatter(sectionYtdPct)}
</td>
</tr>
</Fragment>
);
})}
{/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
{cadFormatter(totals.monthVariation)}
</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
{pctFormatter(totalMonthPct)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.ytdActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)}
</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{pctFormatter(totalYtdPct)}
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -59,9 +59,6 @@ export default function CategoryOverTimeChart({
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null); const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
// Reads the name-keyed pivot (`data.categories`), which stays top-N-capped
// (Issue #264 sidecar) so the stacked series never explode to 80+ layers. The
// full, uncapped hierarchy lives in `data.tree` and drives the table instead.
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name)); const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
const categoryEntries = visibleCategories.map((name, index) => ({ const categoryEntries = visibleCategories.map((name, index) => ({
name, name,

View file

@ -1,15 +1,7 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import type { CategoryOverTimeData } from "../../shared/types";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; import { computeOverTimeResults, type OverTimeType } from "./overTimeResults";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
type OverTimeRenderSection,
} from "./overTimeTableModel";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
@ -41,19 +33,12 @@ const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
interface CategoryOverTimeTableProps { interface CategoryOverTimeTableProps {
data: CategoryOverTimeData; data: CategoryOverTimeData;
hiddenCategories?: Set<string>;
} }
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) { export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
// persisted under a key distinct from the comparable tables. Called before the
// early return so the hook order stays stable.
const groups = useCollapsibleGroups<OverTimeRow>(
OVERTIME_EXPANDED_STORAGE_KEY,
OVERTIME_COLLAPSE_ACCESSORS,
);
if (data.data.length === 0) { if (data.data.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -62,163 +47,15 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
); );
} }
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and const visibleCategories = hiddenCategories?.size
// the result lines are exact, and homonym categories never collide. ? data.categories.filter((name) => !hiddenCategories.has(name))
const analysis = computeOverTimeResults(data); : data.categories;
const { months, hasTransfers, net, beforeTransfers } = analysis;
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree); const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories);
const colSpan = months.length + 2; const colSpan = months.length + 2;
const hasGroups = groups.groupCount(data.tree) > 0;
const allExpanded = groups.allExpanded(data.tree);
// A section = its header, its (visibly) indented hierarchy rows, and a
// leaf-summed subtotal row. Only top-level parents collapse (parity with the
// comparable tables); the subtotal is always the reducer's leaf sum, so it
// never changes when a group is folded away.
const renderSection = (section: OverTimeRenderSection) => (
<Fragment key={section.type}>
{/* Section header */}
<tr className="bg-[var(--muted)]">
<td
colSpan={colSpan}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{t(SECTION_LABEL_KEY[section.type])}
</td>
</tr>
{/* Category rows — neutral leaf/parent magnitudes (not deltas) */}
{groups.visible(section.rows).map((row) => {
const isParent = row.is_parent;
const depth = row.depth;
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<td
className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}
>
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
)}
</td>
{months.map((month, monthIdx) => {
const value = row.monthly[monthIdx] ?? 0;
return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(row.total)}
</td>
</tr>
);
})}
{/* Section subtotal */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(SECTION_TOTAL_KEY[section.type])}
</td>
{months.map((month) => (
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
{cadFormatter(section.monthly[month])}
</td>
))}
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(section.total)}
</td>
</tr>
</Fragment>
);
// A result line (before-transfers subtotal or the net bottom line). Amounts are
// coloured by sign (surplus green / deficit red). Computed from the raw tree,
// so folding groups never moves these figures.
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
<tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
strong ? "border-t-2 border-[var(--border)]" : "border-b border-[var(--border)]"
}`}
>
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t(labelKey)}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(series.monthly[month]) }}
>
{cadFormatter(series.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(series.total) }}
>
{cadFormatter(series.total)}
</td>
</tr>
);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{hasGroups && (
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}> <div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap"> <table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
@ -237,14 +74,114 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{nonTransferSections.map(renderSection)} {sections.map((section) => (
{/* Operating result (revenues expenses), interleaved before the <Fragment key={section.type}>
transfers section only when transfers exist otherwise it equals {/* Section header */}
the net total below and would just be noise. */} <tr className="bg-[var(--muted)]">
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)} <td
{transferSection && renderSection(transferSection)} colSpan={colSpan}
{/* Bottom line: result after netting transfers. */} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
{renderResultRow("reports.compare.resultNet", net, true)} >
{t(SECTION_LABEL_KEY[section.type])}
</td>
</tr>
{/* Category rows — neutral levels, not deltas */}
{section.categories.map((category) => {
const rowTotal = data.data.reduce(
(sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0),
0,
);
return (
<tr key={category} className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40">
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: data.colors[category] }}
/>
{category}
</span>
</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(rowTotal)}
</td>
</tr>
);
})}
{/* Section subtotal */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(SECTION_TOTAL_KEY[section.type])}
</td>
{months.map((month) => (
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
{cadFormatter(section.monthly[month])}
</td>
))}
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(section.total)}
</td>
</tr>
</Fragment>
))}
{/* Result before transfers — shown only when transfers exist */}
{hasTransfers && (
<tr className="border-t-2 border-[var(--border)] font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.resultBeforeTransfers")}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(beforeTransfers.monthly[month]) }}
>
{cadFormatter(beforeTransfers.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(beforeTransfers.total) }}
>
{cadFormatter(beforeTransfers.total)}
</td>
</tr>
)}
{/* Net result — bottom line (income expenses + transfers) */}
<tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
hasTransfers ? "border-b border-[var(--border)]" : "border-t-2 border-[var(--border)]"
}`}
>
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.resultNet")}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(net.monthly[month]) }}
>
{cadFormatter(net.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(net.total) }}
>
{cadFormatter(net.total)}
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -7,13 +7,9 @@ import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps { export interface CompareBudgetViewProps {
year: number; year: number;
month: number; month: number;
/** Account (import source) filter forwarded to getBudgetVsActualData so this
* sub-tab respects the same FilterPanel selection as the rest of the Compare
* page (Issue #276). Empty/omitted = no filter. */
accountIds?: number[];
} }
export default function CompareBudgetView({ year, month, accountIds }: CompareBudgetViewProps) { export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [rows, setRows] = useState<BudgetVsActualRow[]>([]); const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -21,7 +17,7 @@ export default function CompareBudgetView({ year, month, accountIds }: CompareBu
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
getBudgetVsActualData(year, month, accountIds) getBudgetVsActualData(year, month)
.then((data) => { .then((data) => {
if (!cancelled) setRows(data); if (!cancelled) setRows(data);
}) })
@ -31,7 +27,7 @@ export default function CompareBudgetView({ year, month, accountIds }: CompareBu
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [year, month, accountIds]); }, [year, month]);
if (error) { if (error) {
return ( return (

View file

@ -1,10 +1,8 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import { ArrowUpDown } from "lucide-react";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults"; import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
export interface ComparePeriodTableProps { export interface ComparePeriodTableProps {
@ -59,16 +57,6 @@ function deltaColor(value: number, higherIsBetter: boolean): string {
const STORAGE_KEY = "compare-subtotals-position"; const STORAGE_KEY = "compare-subtotals-position";
// Collapse groups keyed by category id; depth/parent mirror the render logic
// below so the hidden rows are exactly a group's indented descendants.
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
keyOf: (row) => String(row.categoryId),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent ?? false,
};
const COMPARE_EXPANDED_STORAGE_KEY = "reports-compare-expanded";
const COL_COUNT = 9; const COL_COUNT = 9;
export default function ComparePeriodTable({ export default function ComparePeriodTable({
@ -93,12 +81,6 @@ export default function ComparePeriodTable({
}); });
}; };
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
const groups = useCollapsibleGroups<CategoryDelta>(
COMPARE_EXPANDED_STORAGE_KEY,
COMPARE_COLLAPSE_ACCESSORS,
);
const monthPrevLabel = previousLabel; const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel; const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
@ -149,12 +131,11 @@ export default function ComparePeriodTable({
{sectionLabels[section.type]} {sectionLabels[section.type]}
</td> </td>
</tr> </tr>
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => { {reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent ?? false; const isParent = row.is_parent ?? false;
const depth = row.depth ?? 0; const depth = row.depth ?? 0;
const isTopParent = isParent && depth === 0; const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1; const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass = const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
@ -177,33 +158,13 @@ export default function ComparePeriodTable({
: `${paddingClass} bg-[var(--card)]` : `${paddingClass} bg-[var(--card)]`
}`} }`}
> >
{isTopParent ? ( <span className="flex items-center gap-2">
<button <span
type="button" className="w-2.5 h-2.5 rounded-full shrink-0"
onClick={() => groups.toggle(row)} style={{ backgroundColor: row.categoryColor }}
aria-expanded={!collapsed} />
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity" {row.categoryName}
> </span>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
)}
</td> </td>
{/* Monthly block */} {/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
@ -340,22 +301,9 @@ export default function ComparePeriodTable({
); );
}; };
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(rows))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button <button
onClick={toggleSubtotals} onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"

View file

@ -1,199 +0,0 @@
// FilterPanel — unit tests (Issue #274).
//
// NOTE: This project has no @testing-library/react / jsdom configured (see
// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
// their own test file, has NO internal state or hooks besides `useTranslation`
// — mocked below into a plain function — so it can be invoked directly as a
// plain function (bypassing JSX/React.createElement's usual `<FilterPanel />`
// call site) and its return value (a plain React-element object tree, since
// JSX creation never touches the DOM) walked directly. This gives genuine
// coverage of the shipped render + selection-wiring code, not a
// re-implementation of its logic in the test.
import { describe, it, expect, vi } from "vitest";
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
i18n: { language: "fr" },
})),
}));
vi.mock("lucide-react", () => ({
Inbox: () => null,
}));
import FilterPanel, { toggleAccountId } from "./FilterPanel";
import type { FilterPanelProps } from "./FilterPanel";
import type { ImportSource } from "../../shared/types";
// ---------------------------------------------------------------------------
// Minimal React-element tree walker (no renderer/DOM involved)
// ---------------------------------------------------------------------------
interface ElementNode {
type: unknown;
props: Record<string, unknown> & { children?: unknown };
}
type FlatNode = ElementNode | string | number;
function isElementNode(x: unknown): x is ElementNode {
return typeof x === "object" && x !== null && "props" in x;
}
function flatten(node: unknown): FlatNode[] {
if (Array.isArray(node)) return node.flatMap(flatten);
if (node === null || node === undefined || typeof node === "boolean") return [];
if (typeof node === "string" || typeof node === "number") return [node];
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
return [];
}
function findCheckboxes(root: unknown): ElementNode[] {
return flatten(root).filter(
(n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
);
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SOURCE_CHEQUING: ImportSource = {
id: 1,
name: "Compte chèque Desjardins",
date_format: "YYYY-MM-DD",
delimiter: ",",
encoding: "utf-8",
column_mapping: "{}",
skip_lines: 0,
has_header: true,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
const SOURCE_VISA: ImportSource = {
...SOURCE_CHEQUING,
id: 2,
name: "Visa Desjardins",
};
function renderPanel(overrides: Partial<FilterPanelProps> = {}) {
const onAccountIdsChange = vi.fn();
const props: FilterPanelProps = {
temporalControl: "TEMPORAL_CONTROL_MARKER",
accountIds: [],
onAccountIdsChange,
accounts: [SOURCE_CHEQUING, SOURCE_VISA],
...overrides,
};
const element = FilterPanel(props);
return { element, onAccountIdsChange };
}
// ---------------------------------------------------------------------------
// toggleAccountId — pure selection logic
// ---------------------------------------------------------------------------
describe("toggleAccountId", () => {
it("adds an absent id to an empty list", () => {
expect(toggleAccountId([], 1)).toEqual([1]);
});
it("appends an absent id after the existing ones", () => {
expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
});
it("removes a present id, preserving the order of the remaining ids", () => {
expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
});
it("removing the only selected id returns an empty array (= all accounts)", () => {
expect(toggleAccountId([1], 1)).toEqual([]);
});
it("does not mutate the input array", () => {
const input = [1, 2];
toggleAccountId(input, 3);
expect(input).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// FilterPanel — render
// ---------------------------------------------------------------------------
describe("FilterPanel — render", () => {
it("renders the injected temporalControl as-is", () => {
const { element } = renderPanel();
expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
});
it("renders one checkbox per account", () => {
const { element } = renderPanel();
expect(findCheckboxes(element)).toHaveLength(2);
});
it("renders each account's name as visible text", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("Compte chèque Desjardins");
expect(text).toContain("Visa Desjardins");
});
it("checks exactly the accounts present in accountIds", () => {
const { element } = renderPanel({ accountIds: [2] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
});
it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
const { element } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
});
it("renders zero checkboxes when accounts is empty, without crashing", () => {
const { element } = renderPanel({ accounts: [] });
expect(findCheckboxes(element)).toHaveLength(0);
});
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("reports.filters.accounts.label");
expect(text).toContain("reports.filters.accounts.hint");
});
});
// ---------------------------------------------------------------------------
// FilterPanel — selection wiring
// ---------------------------------------------------------------------------
describe("FilterPanel — selection wiring", () => {
it("checking an unselected account calls onAccountIdsChange with it added", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[1] = SOURCE_VISA (id 2), currently unselected
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
});
it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[0] = SOURCE_CHEQUING (id 1), currently selected
(checkboxes[0].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([]);
});
it("checking a second account preserves the first (multi-select)", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
(checkboxes[0].props.onChange as () => void)();
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
});
});

View file

@ -1,93 +0,0 @@
// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
//
// Design: a "slot" component. Each report page owns its own temporal state
// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
// via useReportsPeriod, Budget = a plain year) and renders its own control
// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
// enum and does not know which control it is rendering — an earlier design
// unifying temporal state behind such an enum was rejected specifically to
// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
// The only state this component actually owns/shares across report pages is
// the account (import source) filter.
//
// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
// copy in this component deliberately says "sources" everywhere, never
// "comptes"/"accounts", so the two concepts never read as the same filter.
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react";
import type { ImportSource } from "../../shared/types";
export interface FilterPanelProps {
/** The page's own temporal control, rendered as-is. */
temporalControl: ReactNode;
/** Selected import source ids. Empty array = no filter (all sources). */
accountIds: number[];
onAccountIdsChange: (accountIds: number[]) => void;
/** Import sources available to filter on. */
accounts: ImportSource[];
}
/**
* Toggles `id` in/out of `accountIds` (add if absent, remove if present),
* preserving the relative order of the remaining ids. Pure and exported so
* the selection logic is unit-testable without rendering the component.
*/
export function toggleAccountId(accountIds: number[], id: number): number[] {
return accountIds.includes(id)
? accountIds.filter((existing) => existing !== id)
: [...accountIds, id];
}
export default function FilterPanel({
temporalControl,
accountIds,
onAccountIdsChange,
accounts,
}: FilterPanelProps) {
const { t } = useTranslation();
const accountsLabel = t("reports.filters.accounts.label");
return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4 flex flex-wrap items-start gap-4">
<div>{temporalControl}</div>
<div className="flex flex-col gap-1.5">
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
<Inbox size={14} />
{accountsLabel}
</span>
<div
className="flex flex-wrap gap-x-4 gap-y-1.5"
role="group"
aria-label={accountsLabel}
>
{accounts.map((account) => (
<label
key={account.id}
className="flex items-center gap-1.5 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={accountIds.includes(account.id)}
onChange={() =>
onAccountIdsChange(toggleAccountId(accountIds, account.id))
}
className="accent-[var(--primary)]"
/>
{account.name}
</label>
))}
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{t("reports.filters.accounts.hint")}
</span>
</div>
</div>
);
}

View file

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

View file

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

View file

@ -1,41 +1,12 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults"; import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; import type { CategoryOverTimeData } from "../../shared/types";
/** Builds an id-keyed leaf row; `monthly` is index-aligned to the months list. */ function makeData(
function leaf( data: CategoryOverTimeData["data"],
categoryId: number | null, types: Record<string, "income" | "expense" | "transfer">,
categoryName: string, ): CategoryOverTimeData {
monthly: number[], return { categories: Object.keys(types), data, colors: {}, categoryIds: {}, types };
category_type: "income" | "expense" | "transfer",
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
/** Wraps a month list + id-keyed tree into a CategoryOverTimeData. The pivot
* fields are irrelevant here computeOverTimeResults reads `data.tree`, using
* `data.data` only for the (ordered) month list. */
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
} }
describe("OVER_TIME_TYPE_ORDER", () => { describe("OVER_TIME_TYPE_ORDER", () => {
@ -45,21 +16,19 @@ describe("OVER_TIME_TYPE_ORDER", () => {
}); });
describe("computeOverTimeResults", () => { describe("computeOverTimeResults", () => {
it("groups leaves into ordered sections with per-month + total subtotals", () => { it("groups categories into ordered sections with per-month + total subtotals", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"],
[ [
leaf(2, "Rent", [1000, 1000], "expense"), { month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
leaf(4, "Savings", [200, 200], "transfer"), { month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
leaf(1, "Salary", [3000, 3000], "income"),
leaf(3, "Groceries", [500, 700], "expense"),
], ],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
expect(r.months).toEqual(["2025-01", "2025-02"]); expect(r.months).toEqual(["2025-01", "2025-02"]);
// Income-first ordering, regardless of the input row order. // Income-first ordering, regardless of the input category order.
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]); expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
const income = r.sections.find((s) => s.type === "income")!; const income = r.sections.find((s) => s.type === "income")!;
@ -68,7 +37,7 @@ describe("computeOverTimeResults", () => {
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 }); expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
expect(income.total).toBe(6000); expect(income.total).toBe(6000);
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Rent", "Groceries"]); expect(expense.categories).toEqual(["Rent", "Groceries"]);
expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 }); expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
expect(expense.total).toBe(3200); expect(expense.total).toBe(3200);
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 }); expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
@ -77,16 +46,14 @@ describe("computeOverTimeResults", () => {
it("computes net (income expense + transfer) and before-transfers per month", () => { it("computes net (income expense + transfer) and before-transfers per month", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"],
[ [
leaf(1, "Salary", [3000, 3000], "income"), { month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
leaf(2, "Rent", [1000, 1000], "expense"), { month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
leaf(3, "Groceries", [500, 700], "expense"),
leaf(4, "Savings", [200, 200], "transfer"),
], ],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
expect(r.hasTransfers).toBe(true); expect(r.hasTransfers).toBe(true);
// before = income expense // before = income expense
@ -99,11 +66,11 @@ describe("computeOverTimeResults", () => {
it("collapses net to before-transfers when there are no transfers", () => { it("collapses net to before-transfers when there are no transfers", () => {
const data = makeData( const data = makeData(
["2025-01"], [{ month: "2025-01", Salary: 2000, Rent: 2500 }],
[leaf(1, "Salary", [2000], "income"), leaf(2, "Rent", [2500], "expense")], { Salary: "income", Rent: "expense" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent"]);
expect(r.hasTransfers).toBe(false); expect(r.hasTransfers).toBe(false);
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]); expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
@ -113,27 +80,24 @@ describe("computeOverTimeResults", () => {
expect(r.net.total).toBe(-500); expect(r.net.total).toBe(-500);
}); });
it("defaults a leaf with an unknown type to the expense section (defensive)", () => { it("defaults an untyped category (e.g. Other) to expense", () => {
// The tree normally stamps a valid type on every leaf; the reducer still const data = makeData(
// guards against a stray value by folding it into expenses. [{ month: "2025-01", Salary: 1000, Other: 300 }],
const untyped = { { Salary: "income" }, // Other has no type entry
...leaf(9, "Other", [300], "expense"), );
category_type: undefined as unknown as "expense",
};
const data = makeData(["2025-01"], [leaf(1, "Salary", [1000], "income"), untyped]);
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Other"]);
const expense = r.sections.find((s) => s.type === "expense")!; const expense = r.sections.find((s) => s.type === "expense")!;
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Other"]); expect(expense.categories).toEqual(["Other"]);
expect(expense.total).toBe(300); expect(expense.total).toBe(300);
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300 expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300
}); });
it("drops empty sections and still nets a lone transfer section", () => { it("drops empty sections and still nets a lone transfer section", () => {
const data = makeData(["2025-01"], [leaf(1, "Move", [100], "transfer")]); const data = makeData([{ month: "2025-01", Move: 100 }], { Move: "transfer" });
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Move"]);
expect(r.sections.map((s) => s.type)).toEqual(["transfer"]); expect(r.sections.map((s) => s.type)).toEqual(["transfer"]);
expect(r.hasTransfers).toBe(true); expect(r.hasTransfers).toBe(true);
@ -141,51 +105,29 @@ describe("computeOverTimeResults", () => {
expect(r.net.monthly["2025-01"]).toBe(100); expect(r.net.monthly["2025-01"]).toBe(100);
}); });
it("keeps homonym categories of different types apart (Résultat net correct) — Issue #264", () => { it("only counts the categories passed as visible (hidden ones excluded)", () => {
// Two DIFFERENT categories both named "Divers": one income, one expense.
// A name-keyed pivot would merge them into a single cell and a single type;
// the id-keyed tree keeps them distinct, so the net stays correct.
const data = makeData( const data = makeData(
["2025-01"], [{ month: "2025-01", Salary: 2000, Rent: 500, Hidden: 999 }],
[leaf(1, "Divers", [1000], "income"), leaf(2, "Divers", [300], "expense")], { Salary: "income", Rent: "expense", Hidden: "expense" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Rent"]); // Hidden filtered out upstream
const income = r.sections.find((s) => s.type === "income")!;
const expense = r.sections.find((s) => s.type === "expense")!;
expect(income.total).toBe(1000);
expect(expense.total).toBe(300);
// Net = 1000 300 = 700, NOT 0 (which a name collision would produce).
expect(r.net.monthly["2025-01"]).toBe(700);
});
it("sums leaves only — a parent subtotal row is never double-counted — Issue #264", () => {
// The tree carries both a parent subtotal (is_parent) and its leaves. The
// reducer must add up leaves only, or the parent's 800 would be counted twice.
const parent = { ...leaf(10, "Dépenses", [800], "expense"), is_parent: true };
const child1 = { ...leaf(11, "Épicerie", [500], "expense"), parent_id: 10, depth: 1 };
const child2 = { ...leaf(12, "Restaurant", [300], "expense"), parent_id: 10, depth: 1 };
const income = leaf(1, "Salary", [2000], "income");
const data = makeData(["2025-01"], [income, parent, child1, child2]);
const r = computeOverTimeResults(data);
const expense = r.sections.find((s) => s.type === "expense")!; const expense = r.sections.find((s) => s.type === "expense")!;
// 500 + 300 = 800 (the parent's own 800 subtotal is excluded). expect(expense.total).toBe(500);
expect(expense.total).toBe(800); expect(r.net.monthly["2025-01"]).toBe(1500); // 2000 500
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Épicerie", "Restaurant"]);
// Net = 2000 800 = 1200, not 2000 1600.
expect(r.net.monthly["2025-01"]).toBe(1200);
}); });
it("treats a category absent from a month as zero", () => { it("treats a category absent from a month as zero", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"], [
[leaf(1, "Salary", [1000, 1000], "income"), leaf(2, "Bonus", [0, 500], "income")], { month: "2025-01", Salary: 1000 },
{ month: "2025-02", Salary: 1000, Bonus: 500 },
],
{ Salary: "income", Bonus: "income" },
); );
const r = computeOverTimeResults(data); const r = computeOverTimeResults(data, ["Salary", "Bonus"]);
const income = r.sections.find((s) => s.type === "income")!; const income = r.sections.find((s) => s.type === "income")!;
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 }); expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });

View file

@ -1,12 +1,12 @@
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; import type { CategoryOverTimeData, CategoryOverTimeItem } from "../../shared/types";
export type OverTimeType = "income" | "expense" | "transfer"; export type OverTimeType = "income" | "expense" | "transfer";
/** /**
* Income-statement reading order: revenue first, then expenses, then transfers, * Income-statement reading order: revenue first, then expenses, then transfers,
* so the "net result" row reads naturally at the bottom (Income Expenses + * so the "net result" row reads naturally at the bottom (Income Expenses +
* Transfers). Matches the compare report's `COMPARE_TYPE_ORDER`, which is also * Transfers). Note this is income-first, unlike the compare report's
* income-first since issue #253 (the earlier expense-first note is obsolete). * `COMPARE_TYPE_ORDER` (expense-first) see issue #256.
*/ */
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = { export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
income: 0, income: 0,
@ -22,18 +22,10 @@ export interface OverTimeSeries {
total: number; total: number;
} }
/** /** One rendered section: a type, its member categories, and per-month + total subtotals. */
* One rendered section: a type, its member leaf rows (id-keyed, from the tree), export interface OverTimeSection extends OverTimeSeries {
* and per-month + total subtotals. `rows` are the actual leaves in the section
* (never subtotal rows), each carrying its own index-aligned `monthly[]`.
*/
export interface OverTimeSection {
type: OverTimeType; type: OverTimeType;
rows: OverTimeRow[]; categories: string[];
/** month (YYYY-MM) -> section subtotal */
monthly: Record<string, number>;
/** sum across every month */
total: number;
} }
export interface OverTimeAnalysis { export interface OverTimeAnalysis {
@ -49,53 +41,60 @@ export interface OverTimeAnalysis {
beforeTransfers: OverTimeSeries; beforeTransfers: OverTimeSeries;
} }
/** /** Reads a numeric category cell off a pivot row, treating anything non-numeric as 0. */
* Pure reducer that turns the category-over-time report into an "income function cellValue(item: CategoryOverTimeItem | undefined, category: string): number {
* statement" shape: per-type sections with per-month subtotals, plus the const v = (item as Record<string, unknown> | undefined)?.[category];
* net-result rows. return typeof v === "number" ? v : 0;
* }
* Consumes the **id-keyed tree** (`data.tree`) NOT the name-keyed pivot so
* two homonym categories of different types never collide: each leaf carries its
* own `category_id`-resolved `category_type`, and the subtotals + result lines
* are summed over the tree's LEAVES only (never its `is_parent` subtotal rows,
* which would double-count their own descendants). Leaf `monthly[]` values are
* positive magnitudes (`ABS(SUM())` in SQL), so income/expense/transfer
* subtotals are all 0 and the net is `income expense + transfer`.
*
* The tree is already ordered income expense transfer, so section ordering
* falls out of the leaves' own `category_type`.
*/
export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnalysis {
const months = data.data.map((d) => d.month);
// Leaves only — subtotals (`is_parent`) are excluded so a parent group can /**
// never double-count against its own descendants. * Pure reducer that turns the category-over-time pivot into an "income statement"
const typeOf = (row: OverTimeRow): OverTimeType => { * shape: per-type sections with per-month subtotals, plus the net-result rows.
const t = row.category_type; *
* Values coming from `getCategoryOverTime` are positive magnitudes (`ABS(SUM())`
* in SQL), so income/expense/transfer subtotals are all 0, and the net is
* `income expense + transfer`. A category whose type is unknown (e.g. the
* "Other" bucket that aggregates non-top-N categories) defaults to `expense`,
* mirroring the `COALESCE(c.type, 'expense')` used by the service.
*
* Extracted from `CategoryOverTimeTable` and unit-tested independently the
* project has no React render harness, so the arithmetic lives in a pure module.
*/
export function computeOverTimeResults(
data: CategoryOverTimeData,
visibleCategories: string[],
): OverTimeAnalysis {
const months = data.data.map((d) => d.month);
const itemByMonth = new Map<string, CategoryOverTimeItem>();
for (const item of data.data) itemByMonth.set(item.month, item);
const typeOf = (cat: string): OverTimeType => {
const t = data.types[cat];
return t === "income" || t === "transfer" ? t : "expense"; return t === "income" || t === "transfer" ? t : "expense";
}; };
const byType: Record<OverTimeType, OverTimeRow[]> = { income: [], expense: [], transfer: [] };
for (const row of data.tree) { // Bucket visible categories by type, preserving their incoming (magnitude) order.
if (!row.is_parent) byType[typeOf(row)].push(row); const byType: Record<OverTimeType, string[]> = { income: [], expense: [], transfer: [] };
} for (const cat of visibleCategories) byType[typeOf(cat)].push(cat);
const buildSection = (type: OverTimeType): OverTimeSection => { const buildSection = (type: OverTimeType): OverTimeSection => {
const rows = byType[type]; const categories = byType[type];
const monthly: Record<string, number> = {}; const monthly: Record<string, number> = {};
let total = 0; let total = 0;
months.forEach((month, i) => { for (const month of months) {
const sum = rows.reduce((s, row) => s + (row.monthly[i] ?? 0), 0); const item = itemByMonth.get(month);
const sum = categories.reduce((s, cat) => s + cellValue(item, cat), 0);
monthly[month] = sum; monthly[month] = sum;
total += sum; total += sum;
}); }
return { type, rows, monthly, total }; return { type, categories, monthly, total };
}; };
const income = buildSection("income"); const income = buildSection("income");
const expense = buildSection("expense"); const expense = buildSection("expense");
const transfer = buildSection("transfer"); const transfer = buildSection("transfer");
const hasTransfers = transfer.rows.length > 0; const hasTransfers = transfer.categories.length > 0;
const net: OverTimeSeries = { monthly: {}, total: 0 }; const net: OverTimeSeries = { monthly: {}, total: 0 };
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 }; const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
@ -109,7 +108,7 @@ export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnal
} }
const sections = [income, expense, transfer] const sections = [income, expense, transfer]
.filter((s) => s.rows.length > 0) .filter((s) => s.categories.length > 0)
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]); .sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
return { months, sections, hasTransfers, net, beforeTransfers }; return { months, sections, hasTransfers, net, beforeTransfers };

View file

@ -1,188 +0,0 @@
import { describe, it, expect } from "vitest";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { visibleRows } from "../../utils/collapsibleRows";
import { computeOverTimeResults } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
} from "./overTimeTableModel";
type RowType = "income" | "expense" | "transfer";
/** Builds one id-keyed tree row; `monthly` is index-aligned to the months list. */
function mkRow(
categoryId: number,
categoryName: string,
monthly: number[],
category_type: RowType,
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
}
const MONTHS = ["2025-01", "2025-02"];
// A realistic parent-first depth-first tree (income → expense → transfer):
// Revenus (parent, id 1)
// Paie (leaf, id 11)
// Bonus (leaf, id 12)
// Dépenses (parent, id 2)
// Épicerie (leaf, id 21)
// Resto (leaf, id 22)
// Loyer (top-level leaf, id 23)
// Épargne (transfer leaf, id 3)
function buildTree(): OverTimeRow[] {
return [
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }),
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }),
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
];
}
const acc = OVERTIME_COLLAPSE_ACCESSORS;
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
describe("overTimeTableModel — storage key", () => {
it("uses a trends-specific key, distinct from the comparable tables", () => {
expect(OVERTIME_EXPANDED_STORAGE_KEY).toBe("reports-trends-expanded");
expect(OVERTIME_EXPANDED_STORAGE_KEY).not.toBe("reports-compare-expanded");
});
});
describe("overTimeTableModel — collapse accessors", () => {
it("keys by category id and reads the snake_case hierarchy block", () => {
const parent = buildTree()[0];
const leaf = buildTree()[1];
expect(acc.keyOf(parent)).toBe("1");
expect(acc.depthOf(leaf)).toBe(1);
expect(acc.isParent(parent)).toBe(true);
expect(acc.isParent(leaf)).toBe(false);
});
});
describe("overTimeTableModel.groupOverTimeSections", () => {
it("splits the tree into ordered sections carrying the FULL hierarchy (parents + leaves)", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
// Income-first, transfer split out for interleaving.
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
expect(transferSection?.type).toBe("transfer");
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
const income = nonTransferSections.find((s) => s.type === "income")!;
expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(income.rows[0].is_parent).toBe(true);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
expect(expense.rows.map((r) => r.categoryName)).toEqual([
"Dépenses",
"Épicerie",
"Resto",
"Loyer",
]);
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
});
it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Paie 6000 + Bonus 500 = 6500 (the Revenus parent's own 6500 is NOT added again).
expect(income.total).toBe(6500);
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
const expense = nonTransferSections.find((s) => s.type === "expense")!;
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded).
expect(expense.total).toBe(3300);
});
it("drops empty sections: no transfers → transferSection is null", () => {
const tree = buildTree().filter((r) => r.category_type !== "transfer");
const data = makeData(MONTHS, tree);
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
expect(transferSection).toBeNull();
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
});
});
describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
it("is collapsed by default: only depth-0 rows survive an empty expanded set", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
// Revenus stays (its own subtotal row); Paie/Bonus are folded away.
expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
// Dépenses folds its children; the top-level leaf Loyer always stays.
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]);
});
it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
});
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Figures come from the raw tree (never the visible rows), so they hold in
// every collapse state.
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income 3300 expense
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
expect(income.total).toBe(6500);
// Folding vs expanding a group only changes how many rows are visible.
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(collapsed.length).toBeLessThan(expanded.length);
// The subtotal the table shows is identical in both states.
expect(income.total).toBe(6500);
});
});

View file

@ -1,89 +0,0 @@
import type { OverTimeRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
/**
* Render-model helpers for the hierarchical trends table (issue #265). Kept in a
* pure module the project has no React render harness, so the grouping and
* result-interleaving logic is unit-tested here while `CategoryOverTimeTable`
* stays a thin renderer.
*/
/**
* Collapse groups keyed by category id; depth/parent mirror the tree the render
* indents by, so a collapsed group hides exactly its indented descendants. The
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
*/
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
keyOf: (row) => String(row.categoryId),
depthOf: (row) => row.depth,
isParent: (row) => row.is_parent,
};
/** Distinct localStorage key so the trends expansion state never collides with
* the comparable tables' `reports-compare-expanded` (issue #265). */
export const OVERTIME_EXPANDED_STORAGE_KEY = "reports-trends-expanded";
/**
* One rendered section: its type, the FULL hierarchy rows of that type (parents
* + leaves, parent-first depth-first straight off `data.tree`), and the
* leaf-summed per-month + total subtotals carried over from the reducer.
*
* `rows` carries the parents (unlike `OverTimeSection.rows`, which is leaves
* only) so the table can render the hierarchy and collapse it; `monthly`/`total`
* stay the reducer's leaf sums, so they are unaffected by which rows are folded
* away the "collapse is purely visual" invariant.
*/
export interface OverTimeRenderSection {
type: OverTimeType;
rows: OverTimeRow[];
/** month (YYYY-MM) -> section subtotal (leaf-summed, collapse-invariant). */
monthly: Record<string, number>;
/** sum across every month (leaf-summed, collapse-invariant). */
total: number;
}
const typeOf = (row: OverTimeRow): OverTimeType => {
const t = row.category_type;
return t === "income" || t === "transfer" ? t : "expense";
};
/**
* Splits the id-keyed tree into render sections, driven by the reducer's own
* (ordered, non-empty, income expense transfer) `analysis.sections` so the
* ordering and the subtotals match `computeOverTimeResults` exactly. Each
* section is given the FULL hierarchy rows of its type (from `tree`), while its
* subtotals stay the reducer's leaf sums.
*
* Returns the non-transfer sections and the transfer section separately so the
* caller can interleave the "result before transfers" line between them
* (parity with `ComparePeriodTable`).
*/
export function groupOverTimeSections(
analysis: OverTimeAnalysis,
tree: OverTimeRow[],
): {
nonTransferSections: OverTimeRenderSection[];
transferSection: OverTimeRenderSection | null;
} {
// Full hierarchy rows (parents + leaves) bucketed by type, tree order kept.
const rowsByType: Record<OverTimeType, OverTimeRow[]> = {
income: [],
expense: [],
transfer: [],
};
for (const row of tree) rowsByType[typeOf(row)].push(row);
const sections: OverTimeRenderSection[] = analysis.sections.map((s) => ({
type: s.type,
rows: rowsByType[s.type],
monthly: s.monthly,
total: s.total,
}));
return {
nonTransferSections: sections.filter((s) => s.type !== "transfer"),
transferSection: sections.find((s) => s.type === "transfer") ?? null,
};
}

View file

@ -1,5 +1,5 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types"; import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
@ -11,14 +11,11 @@ import {
applyTemplate as applyTemplateSvc, applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc, deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService"; } from "../services/budgetService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
interface BudgetState { interface BudgetState {
year: number; year: number;
rows: BudgetYearRow[]; rows: BudgetYearRow[];
templates: BudgetTemplate[]; templates: BudgetTemplate[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: string | null; error: string | null;
@ -29,7 +26,6 @@ type BudgetAction =
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } } | { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_YEAR"; payload: number }; | { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState { function initialState(): BudgetState {
@ -37,7 +33,6 @@ function initialState(): BudgetState {
year: new Date().getFullYear(), year: new Date().getFullYear(),
rows: [], rows: [],
templates: [], templates: [],
accounts: [],
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
error: null, error: null,
@ -59,8 +54,6 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
templates: action.payload.templates, templates: action.payload.templates,
isLoading: false, isLoading: false,
}; };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_YEAR": case "SET_YEAR":
return { ...state, year: action.payload }; return { ...state, year: action.payload };
default: default:
@ -68,17 +61,13 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
} }
} }
// Income-statement reading order: revenue first, then expenses, then const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
// transfers (Issue #278) — matches the compare/trend reports' ordering
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
export function useBudget() { export function useBudget() {
const { accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, undefined, initialState); const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number, ids: number[]) => { const refreshData = useCallback(async (year: number) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
@ -87,7 +76,7 @@ export function useBudget() {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1, ids), getActualTotalsForYear(year - 1),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -375,21 +364,8 @@ export function useBudget() {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshData(state.year, accountIds); refreshData(state.year);
}, [state.year, accountIds, refreshData]); }, [state.year, refreshData]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Compare source filters — 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) });
}
})();
}, []);
const navigateYear = useCallback((delta: -1 | 1) => { const navigateYear = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta }); dispatch({ type: "SET_YEAR", payload: state.year + delta });
@ -400,7 +376,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await upsertBudgetEntry(categoryId, state.year, month, amount); await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year, accountIds); await refreshData(state.year);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -410,7 +386,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, accountIds, refreshData] [state.year, refreshData]
); );
const splitEvenly = useCallback( const splitEvenly = useCallback(
@ -424,7 +400,7 @@ export function useBudget() {
amounts.push(m < remainder ? base + 0.01 : base); amounts.push(m < remainder ? base + 0.01 : base);
} }
await upsertBudgetEntriesForYear(categoryId, state.year, amounts); await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year, accountIds); await refreshData(state.year);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -434,7 +410,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, accountIds, refreshData] [state.year, refreshData]
); );
const saveTemplate = useCallback( const saveTemplate = useCallback(
@ -447,7 +423,7 @@ export function useBudget() {
.filter((r) => !r.is_parent && r.months[0] !== 0) .filter((r) => !r.is_parent && r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] })); .map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries); await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year, accountIds); await refreshData(state.year);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -457,7 +433,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.rows, state.year, accountIds, refreshData] [state.rows, state.year, refreshData]
); );
const applyTemplate = useCallback( const applyTemplate = useCallback(
@ -465,7 +441,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await applyTemplateSvc(templateId, state.year, month); await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year, accountIds); await refreshData(state.year);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -475,7 +451,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, accountIds, refreshData] [state.year, refreshData]
); );
const applyTemplateAllMonths = useCallback( const applyTemplateAllMonths = useCallback(
@ -485,7 +461,7 @@ export function useBudget() {
for (let m = 1; m <= 12; m++) { for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m); await applyTemplateSvc(templateId, state.year, m);
} }
await refreshData(state.year, accountIds); await refreshData(state.year);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -495,7 +471,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, accountIds, refreshData] [state.year, refreshData]
); );
const deleteTemplate = useCallback( const deleteTemplate = useCallback(
@ -503,7 +479,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await deleteTemplateSvc(templateId); await deleteTemplateSvc(templateId);
await refreshData(state.year, accountIds); await refreshData(state.year);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -513,7 +489,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, accountIds, refreshData] [state.year, refreshData]
); );
return { return {

View file

@ -1,94 +0,0 @@
import { useCallback, useState } from "react";
import {
type CollapseAccessors,
collapsibleKeys,
parseStoredExpanded,
serializeExpanded,
visibleRows,
} from "../utils/collapsibleRows";
export interface CollapsibleGroups<T> {
/** Filters a section's rows down to the ones visible under the current state. */
visible: (rows: T[]) => T[];
/** True when this top-level parent row is collapsed (its children are hidden). */
isCollapsed: (row: T) => boolean;
/** Flip one group between collapsed and expanded, then persist. */
toggle: (row: T) => void;
/** Expand every collapsible group found in `rows`, then persist. */
expandAll: (rows: T[]) => void;
/** Collapse every group (back to the default), then persist. */
collapseAll: () => void;
/** True when `rows` has at least one group and all of them are expanded. */
allExpanded: (rows: T[]) => boolean;
/** Number of collapsible top-level groups in `rows`. */
groupCount: (rows: T[]) => number;
}
/**
* Per-report collapse/expand state for the hierarchical comparable tables
* (issue #254). Groups are collapsed by default (issue #260): the persisted
* value is the set of *expanded* group keys, so a first-ever visit (empty set)
* shows every group collapsed with only its subtotal, while any group the user
* expands is remembered.
*
* Persistence uses localStorage, the same synchronous store the sibling
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
* per report via a distinct `storageKey`.
*/
export function useCollapsibleGroups<T>(
storageKey: string,
acc: CollapseAccessors<T>,
): CollapsibleGroups<T> {
const [expanded, setExpanded] = useState<Set<string>>(() =>
parseStoredExpanded(
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
),
);
const persist = useCallback(
(next: Set<string>) => {
try {
localStorage.setItem(storageKey, serializeExpanded(next));
} catch {
// Best-effort: a write failure just means the next session falls back
// to the collapsed default.
}
setExpanded(next);
},
[storageKey],
);
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
const toggle = useCallback(
(row: T) => {
const key = acc.keyOf(row);
const next = new Set(expanded);
if (next.has(key)) next.delete(key);
else next.add(key);
persist(next);
},
[expanded, acc, persist],
);
const expandAll = useCallback(
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
[acc, persist],
);
const collapseAll = useCallback(() => persist(new Set()), [persist]);
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
const allExpanded = useCallback(
(rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((k) => expanded.has(k));
},
[expanded, acc],
);
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
}

View file

@ -1,7 +1,6 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta, ImportSource } from "../shared/types"; import type { CategoryDelta } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod"; import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
@ -14,7 +13,6 @@ interface State {
year: number; year: number;
month: number; month: number;
rows: CategoryDelta[]; rows: CategoryDelta[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -25,7 +23,6 @@ type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
/** /**
@ -96,7 +93,6 @@ const initialState: State = {
year: defaultRef.year, year: defaultRef.year,
month: defaultRef.month, month: defaultRef.month,
rows: [], rows: [],
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -113,8 +109,6 @@ function reducer(state: State, action: Action): State {
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_ROWS": case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null }; return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -123,26 +117,20 @@ function reducer(state: State, action: Action): State {
} }
export function useCompare() { export function useCompare() {
const { from, to, accountIds } = useReportsPeriod(); const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback( const fetch = useCallback(
async ( async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
mode: CompareMode,
subMode: CompareSubMode,
year: number,
month: number,
ids: number[],
) => {
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
const rows = const rows =
subMode === "mom" subMode === "mom"
? await getCompareMonthOverMonth(year, month, ids) ? await getCompareMonthOverMonth(year, month)
: await getCompareYearOverYear(year, month, ids); : await getCompareYearOverYear(year, month);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows }); dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) { } catch (e) {
@ -154,21 +142,8 @@ export function useCompare() {
); );
useEffect(() => { useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month, accountIds); fetch(state.mode, state.subMode, state.year, state.month);
}, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]); }, [fetch, state.mode, state.subMode, state.year, state.month]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Transactions source filters — 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) });
}
})();
}, []);
// Keep the reference month in sync with the URL period when the user navigates // Keep the reference month in sync with the URL period when the user navigates
// via PeriodSelector — but not on mount. The ref is seeded with the initial // via PeriodSelector — but not on mount. The ref is seeded with the initial

View file

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

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod"; import { resolveReportsPeriod } from "./useReportsPeriod";
describe("resolveReportsPeriod", () => { describe("resolveReportsPeriod", () => {
const fixedToday = new Date("2026-04-14T12:00:00Z"); const fixedToday = new Date("2026-04-14T12:00:00Z");
@ -51,76 +51,3 @@ describe("resolveReportsPeriod", () => {
expect(result.to).toBe("2026-12-31"); expect(result.to).toBe("2026-12-31");
}); });
}); });
describe("parseAccountIds", () => {
it("defaults to an empty array (no filter) when the sources param is absent", () => {
expect(parseAccountIds(null)).toEqual([]);
});
it("defaults to an empty array for an empty string", () => {
expect(parseAccountIds("")).toEqual([]);
});
it("parses a comma-separated list of ids", () => {
expect(parseAccountIds("3,7,12")).toEqual([3, 7, 12]);
});
it("trims whitespace around tokens", () => {
expect(parseAccountIds(" 3 , 7 ,12")).toEqual([3, 7, 12]);
});
it("parses a single id", () => {
expect(parseAccountIds("42")).toEqual([42]);
});
it("drops non-numeric tokens but keeps the valid ones (rejects non-numeric)", () => {
expect(parseAccountIds("3,abc,7")).toEqual([3, 7]);
});
it("drops decimal tokens (not a finite integer)", () => {
expect(parseAccountIds("3,4.5,7")).toEqual([3, 7]);
});
it("drops empty tokens produced by stray/trailing commas", () => {
expect(parseAccountIds("3,,7,")).toEqual([3, 7]);
});
it("drops exponential/hex-looking tokens that Number() would otherwise coerce", () => {
expect(parseAccountIds("3,1e3,0x10,7")).toEqual([3, 7]);
});
it("drops unsafe-integer tokens (too large to be a reliable id)", () => {
expect(parseAccountIds("3,99999999999999999999,7")).toEqual([3, 7]);
});
it("returns an empty array when every token is invalid", () => {
expect(parseAccountIds("abc,def")).toEqual([]);
});
});
describe("serializeAccountIds", () => {
it("joins ids into a comma-separated string", () => {
expect(serializeAccountIds([3, 7, 12])).toBe("3,7,12");
});
it("serializes a single id", () => {
expect(serializeAccountIds([42])).toBe("42");
});
it("returns null for an empty array so the param is removed (no filter)", () => {
expect(serializeAccountIds([])).toBeNull();
});
it("drops invalid entries (e.g. NaN) before serializing", () => {
expect(serializeAccountIds([3, NaN, 7])).toBe("3,7");
});
it("returns null when every entry is invalid", () => {
expect(serializeAccountIds([NaN, Infinity])).toBeNull();
});
it("round-trips through parseAccountIds", () => {
const ids = [1, 2, 3];
expect(parseAccountIds(serializeAccountIds(ids))).toEqual(ids);
});
});

View file

@ -26,43 +26,6 @@ function currentYearRange(today: Date = new Date()): { from: string; to: string
return { from: `${year}-01-01`, to: `${year}-12-31` }; return { from: `${year}-01-01`, to: `${year}-12-31` };
} }
// Matches a bare (optionally negative) integer token, e.g. "3" or "-3", but
// not "3.5", "abc", "" or "1e3" — deliberately stricter than `Number(...)` so
// that hex/exponential-notation strings from a hand-edited URL don't sneak
// through as valid ids.
const INTEGER_TOKEN = /^-?\d+$/;
/**
* Pure parser for the `sources` query param (comma-separated account/import
* source ids), exposed for the same testability reason as
* `resolveReportsPeriod`. Invalid tokens (non-numeric, decimal, empty from a
* stray comma, or too large to be a safe integer) are dropped individually
* rather than invalidating the whole list a single corrupted token in a
* bookmarked/hand-edited URL should not silently discard the rest of an
* otherwise valid selection. Missing param or no valid token `[]`, which
* means "no filter" (all accounts).
*/
export function parseAccountIds(raw: string | null): number[] {
if (!raw) return [];
return raw
.split(",")
.map((token) => token.trim())
.filter((token) => INTEGER_TOKEN.test(token))
.map(Number)
.filter((n) => Number.isSafeInteger(n));
}
/**
* Inverse of `parseAccountIds`: serializes account ids back to the `sources`
* query param value, or `null` when the param should be removed from the URL
* entirely (empty/all-invalid selection = no filter mirrors how `from`/`to`
* are omitted for a non-custom period).
*/
export function serializeAccountIds(accountIds: number[]): string | null {
const valid = accountIds.filter((n) => Number.isSafeInteger(n));
return valid.length > 0 ? valid.join(",") : null;
}
/** /**
* Pure resolver used by the hook and unit tests. Exposed to keep the core * Pure resolver used by the hook and unit tests. Exposed to keep the core
* logic hookless and testable without rendering a router. * logic hookless and testable without rendering a router.
@ -94,19 +57,15 @@ export interface UseReportsPeriodResult {
from: string; from: string;
to: string; to: string;
period: DashboardPeriod; period: DashboardPeriod;
accountIds: number[];
setPeriod: (period: DashboardPeriod) => void; setPeriod: (period: DashboardPeriod) => void;
setCustomDates: (from: string, to: string) => void; setCustomDates: (from: string, to: string) => void;
setAccountIds: (accountIds: number[]) => void;
} }
/** /**
* Reads/writes the active reporting period and, additively, the account * Reads/writes the active reporting period via the URL query string so it is
* (import source) filter via the URL query string so both are bookmarkable * bookmarkable and shared across the four report sub-routes.
* and shared across the report sub-routes.
* *
* Defaults to the current civil year (Jan 1 Dec 31) with no account filter * Defaults to the current civil year (Jan 1 Dec 31).
* (`accountIds: []`, meaning all accounts).
*/ */
export function useReportsPeriod(): UseReportsPeriodResult { export function useReportsPeriod(): UseReportsPeriodResult {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -114,15 +73,12 @@ export function useReportsPeriod(): UseReportsPeriodResult {
const rawPeriod = searchParams.get("period"); const rawPeriod = searchParams.get("period");
const rawFrom = searchParams.get("from"); const rawFrom = searchParams.get("from");
const rawTo = searchParams.get("to"); const rawTo = searchParams.get("to");
const rawSources = searchParams.get("sources");
const { from, to, period } = useMemo( const { from, to, period } = useMemo(
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod), () => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
[rawPeriod, rawFrom, rawTo], [rawPeriod, rawFrom, rawTo],
); );
const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]);
const setPeriod = useCallback( const setPeriod = useCallback(
(next: DashboardPeriod) => { (next: DashboardPeriod) => {
setSearchParams( setSearchParams(
@ -159,24 +115,5 @@ export function useReportsPeriod(): UseReportsPeriodResult {
[setSearchParams], [setSearchParams],
); );
const setAccountIds = useCallback( return { from, to, period, setPeriod, setCustomDates };
(nextAccountIds: number[]) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
const serialized = serializeAccountIds(nextAccountIds);
if (serialized === null) {
params.delete("sources");
} else {
params.set("sources", serialized);
}
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
return { from, to, period, accountIds, setPeriod, setCustomDates, setAccountIds };
} }

View file

@ -1,7 +1,6 @@
import { useReducer, useEffect, useRef, useCallback } from "react"; import { useReducer, useEffect, useRef, useCallback } from "react";
import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types"; import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService"; import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory"; export type TrendsSubView = "global" | "byCategory";
@ -10,7 +9,6 @@ interface State {
subView: TrendsSubView; subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[]; monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -20,14 +18,12 @@ type Action =
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] } | { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData } | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
const initialState: State = { const initialState: State = {
subView: "byCategory", subView: "global",
monthlyTrends: [], monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -42,8 +38,6 @@ function reducer(state: State, action: Action): State {
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null }; return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME": case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null }; return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -52,49 +46,33 @@ function reducer(state: State, action: Action): State {
} }
export function useTrends() { export function useTrends() {
const { from, to, accountIds } = useReportsPeriod(); const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback( const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => { const id = ++fetchIdRef.current;
const id = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_LOADING", payload: true }); try {
try { if (subView === "global") {
if (subView === "global") { const data = await getMonthlyTrends(dateFrom, dateTo);
const data = await getMonthlyTrends(dateFrom, dateTo, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
}
} catch (e) {
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
} }
}, } catch (e) {
[] if (id !== fetchIdRef.current) return;
); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
useEffect(() => {
fetch(state.subView, from, to, accountIds);
}, [fetch, state.subView, from, to, accountIds]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Transactions single-source filter — see useTransactions.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) });
}
})();
}, []); }, []);
useEffect(() => {
fetch(state.subView, from, to);
}, [fetch, state.subView, from, to]);
const setSubView = useCallback((sv: TrendsSubView) => { const setSubView = useCallback((sv: TrendsSubView) => {
dispatch({ type: "SET_SUBVIEW", payload: sv }); dispatch({ type: "SET_SUBVIEW", payload: sv });
}, []); }, []);

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"kpiSectionTitle": "Key indicators", "balance": "Balance",
"income": "Income", "income": "Income",
"expenses": "Expenses", "expenses": "Expenses",
"net": "Net", "net": "Net",
@ -50,11 +50,10 @@
"help": { "help": {
"title": "How to use the Dashboard", "title": "How to use the Dashboard",
"tips": [ "tips": [
"The filter bar at the top picks the date range and which import sources to include", "Use the period selector (top right) to view different time ranges",
"The key indicators (income, expenses, net, savings rate) cover the chosen reference month, with their change vs the previous month and vs last year", "Summary cards show your balance, income, and expenses for the selected period",
"The \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source", "The pie chart breaks down your expenses by category",
"Rising/falling categories and budget adherence summarize the reference month's changes", "Recent transactions are listed at the bottom"
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
] ]
} }
}, },
@ -380,10 +379,6 @@
"budgetVsActual": "Budget vs Actual", "budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top", "subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom", "subtotalsOnBottom": "Subtotals on bottom",
"collapse": {
"expandAll": "Expand all",
"collapseAll": "Collapse all"
},
"detail": { "detail": {
"showAmounts": "Show amounts", "showAmounts": "Show amounts",
"hideAmounts": "Hide amounts" "hideAmounts": "Hide amounts"
@ -393,18 +388,15 @@
"search": "Search...", "search": "Search...",
"all": "All", "all": "All",
"none": "None", "none": "None",
"allTypes": "All types", "allTypes": "All types"
"accounts": {
"label": "Import sources",
"hint": "No selection = all sources"
}
}, },
"bva": { "bva": {
"monthly": "Monthly", "monthly": "Monthly",
"ytd": "Year-to-Date", "ytd": "Year-to-Date",
"dollarVar": "$ Var", "dollarVar": "$ Var",
"pctVar": "% Var", "pctVar": "% Var",
"noData": "No budget or transaction data for this period." "noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
}, },
"export": "Export", "export": "Export",
"month": "Month", "month": "Month",

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"kpiSectionTitle": "Indicateurs clés", "balance": "Solde",
"income": "Revenus", "income": "Revenus",
"expenses": "Dépenses", "expenses": "Dépenses",
"net": "Net", "net": "Net",
@ -50,11 +50,10 @@
"help": { "help": {
"title": "Comment utiliser le tableau de bord", "title": "Comment utiliser le tableau de bord",
"tips": [ "tips": [
"Le filtre en haut choisit la plage de dates et les sources d'import à inclure", "Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates",
"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", "Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée",
"La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import", "Le graphique circulaire détaille vos dépenses par catégorie",
"Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence", "Les transactions récentes sont listées en bas de page"
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
] ]
} }
}, },
@ -380,10 +379,6 @@
"budgetVsActual": "Budget vs Réel", "budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut", "subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas", "subtotalsOnBottom": "Sous-totaux en bas",
"collapse": {
"expandAll": "Tout déplier",
"collapseAll": "Tout replier"
},
"detail": { "detail": {
"showAmounts": "Afficher les montants", "showAmounts": "Afficher les montants",
"hideAmounts": "Masquer les montants" "hideAmounts": "Masquer les montants"
@ -393,18 +388,15 @@
"search": "Rechercher...", "search": "Rechercher...",
"all": "Toutes", "all": "Toutes",
"none": "Aucune", "none": "Aucune",
"allTypes": "Tous les types", "allTypes": "Tous les types"
"accounts": {
"label": "Sources d'import",
"hint": "Aucune sélection = toutes les sources"
}
}, },
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ Écart", "dollarVar": "$ Écart",
"pctVar": "% Écart", "pctVar": "% Écart",
"noData": "Aucune donnée de budget ou de transaction pour cette période." "noData": "Aucune donnée de budget ou de transaction pour cette période.",
"titlePrefix": "Budget vs Réel pour le mois de"
}, },
"export": "Exporter", "export": "Exporter",
"month": "Mois", "month": "Mois",

View file

@ -1,15 +1,12 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import FilterPanel from "../components/reports/FilterPanel";
import { useBudget } from "../hooks/useBudget"; import { useBudget } from "../hooks/useBudget";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import YearNavigator from "../components/budget/YearNavigator"; import YearNavigator from "../components/budget/YearNavigator";
import BudgetTable from "../components/budget/BudgetTable"; import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions"; import TemplateActions from "../components/budget/TemplateActions";
export default function BudgetPage() { export default function BudgetPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { accountIds, setAccountIds } = useReportsPeriod();
const { const {
state, state,
navigateYear, navigateYear,
@ -21,32 +18,28 @@ export default function BudgetPage() {
deleteTemplate, deleteTemplate,
} = useBudget(); } = useBudget();
const { year, rows, templates, accounts, isLoading, isSaving, error } = state; const { year, rows, templates, isLoading, isSaving, error } = state;
return ( return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"> <div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("budget.title")}</h1> <h1 className="text-2xl font-bold">{t("budget.title")}</h1>
<PageHelp helpKey="budget" /> <PageHelp helpKey="budget" />
</div> </div>
<TemplateActions <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
templates={templates} <TemplateActions
onApply={applyTemplate} templates={templates}
onApplyAllMonths={applyTemplateAllMonths} onApply={applyTemplate}
onSave={saveTemplate} onApplyAllMonths={applyTemplateAllMonths}
onDelete={deleteTemplate} onSave={saveTemplate}
disabled={isSaving} onDelete={deleteTemplate}
/> disabled={isSaving}
/>
<YearNavigator year={year} onNavigate={navigateYear} />
</div>
</div> </div>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
{error && ( {error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20"> <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error} {error}

View file

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

View file

@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import CompareModeTabs from "../components/reports/CompareModeTabs"; import CompareModeTabs from "../components/reports/CompareModeTabs";
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle"; import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
@ -26,8 +25,7 @@ function formatMonthLabel(year: number, month: number, language: string): string
export default function ReportsComparePage() { export default function ReportsComparePage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
useReportsPeriod();
const { const {
mode, mode,
subMode, subMode,
@ -37,7 +35,6 @@ export default function ReportsComparePage() {
year, year,
month, month,
rows, rows,
accounts,
isLoading, isLoading,
error, error,
} = useCompare(); } = useCompare();
@ -93,25 +90,21 @@ export default function ReportsComparePage() {
</div> </div>
</div> </div>
<FilterPanel <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
temporalControl={ <div className="flex items-center gap-3 flex-wrap">
<CompareReferenceMonthPicker <CompareReferenceMonthPicker
year={year} year={year}
month={month} month={month}
onChange={setReferencePeriod} onChange={setReferencePeriod}
/> />
} {showActualControls && (
accountIds={accountIds} <CompareSubModeToggle value={subMode} onChange={setSubMode} />
onAccountIdsChange={setAccountIds} )}
accounts={accounts}
/>
{showActualControls && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div> </div>
)} {showActualControls && (
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
)}
</div>
{error && ( {error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6"> <div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
@ -120,7 +113,7 @@ export default function ReportsComparePage() {
)} )}
{mode === "budget" ? ( {mode === "budget" ? (
<CompareBudgetView year={year} month={month} accountIds={accountIds} /> <CompareBudgetView year={year} month={month} />
) : viewMode === "chart" ? ( ) : viewMode === "chart" ? (
<ComparePeriodChart <ComparePeriodChart
rows={rows} rows={rows}

View file

@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable"; import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
@ -22,9 +21,9 @@ const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() { export default function ReportsTrendsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends(); const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table")); const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType()); const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
@ -56,56 +55,50 @@ export default function ReportsTrendsPage() {
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1> <h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
</div> </div>
<FilterPanel <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
temporalControl={ <PeriodSelector
<PeriodSelector value={period}
value={period} onChange={setPeriod}
onChange={setPeriod} customDateFrom={from}
customDateFrom={from} customDateTo={to}
customDateTo={to} onCustomDateChange={setCustomDates}
onCustomDateChange={setCustomDates} />
/> <div className="flex gap-2 items-center flex-wrap">
} <div className="inline-flex gap-1">
accountIds={accountIds} <button
onAccountIdsChange={setAccountIds} type="button"
accounts={accounts} onClick={() => setSubView("global")}
/> aria-pressed={subView === "global"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
<div className="flex gap-2 items-center justify-end flex-wrap mb-6"> subView === "global"
<div className="inline-flex gap-1"> ? "bg-[var(--primary)] text-white"
<button : "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
type="button" }`}
onClick={() => setSubView("global")} >
aria-pressed={subView === "global"} {t("reports.trends.subviewGlobal")}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ </button>
subView === "global" <button
? "bg-[var(--primary)] text-white" type="button"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]" onClick={() => setSubView("byCategory")}
}`} aria-pressed={subView === "byCategory"}
> className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
{t("reports.trends.subviewGlobal")} subView === "byCategory"
</button> ? "bg-[var(--primary)] text-white"
<button : "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
type="button" }`}
onClick={() => setSubView("byCategory")} >
aria-pressed={subView === "byCategory"} {t("reports.trends.subviewByCategory")}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ </button>
subView === "byCategory" </div>
? "bg-[var(--primary)] text-white" {subView === "byCategory" && viewMode === "chart" && (
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]" <TrendsChartTypeToggle
}`} value={chartType}
> onChange={setChartType}
{t("reports.trends.subviewByCategory")} storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
</button> />
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div> </div>
{subView === "byCategory" && viewMode === "chart" && (
<TrendsChartTypeToggle
value={chartType}
onChange={setChartType}
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
/>
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div> </div>
{error && ( {error && (
@ -130,7 +123,7 @@ export default function ReportsTrendsPage() {
chartType={chartType} chartType={chartType}
/> />
) : ( ) : (
<CategoryOverTimeTable data={categoryOverTime} /> <CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
)} )}
</div> </div>
); );

View file

@ -1,120 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
vi.mock("./db", () => {
const getDb = vi.fn();
return { getDb };
});
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories,
// getBudgetEntriesForYear, and getActualsByCategoryRange for the month + YTD
// windows). Only the two `getActualsByCategoryRange` calls query `transactions`
// and are eligible for the accountIds filter — budgets are not tied to an
// account, so the categories/entries queries must never carry the clause.
describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
it("without accountIds, the actuals queries carry no source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
expect(actualsCalls.length).toBe(2); // month + YTD
for (const [sql, params] of actualsCalls) {
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toHaveLength(2); // dateFrom, dateTo only
}
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, []);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
for (const [sql, params] of actualsCalls) {
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toHaveLength(2);
}
});
it("applies a parameterized accountIds IN clause to both the month and YTD actuals queries", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, [4, 9]);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
expect(actualsCalls.length).toBe(2);
for (const [sql, params] of actualsCalls) {
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(sql as string).not.toContain("4,9");
expect(params as unknown[]).toEqual(expect.arrayContaining([4, 9]));
}
});
it("never adds the filter to the categories/budget-entries queries (a budget is not tied to an account)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, [4]);
const categoriesCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("FROM categories"),
)!;
const budgetEntriesCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("FROM budget_entries"),
)!;
expect(categoriesCall[0]).not.toContain("source_id");
expect(budgetEntriesCall[0]).not.toContain("source_id");
});
});
// getActualTotalsForYear is the previous-year actuals reference column
// consumed by useBudget (Issue #276) — a thin wrapper over the same
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.
describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => {
it("without accountIds, the query carries no source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025);
expect(mockSelect).toHaveBeenCalledTimes(1);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025, []);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025, [4, 9]);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]);
});
});

View file

@ -1,5 +1,4 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
Category, Category,
BudgetEntry, BudgetEntry,
@ -182,49 +181,34 @@ export async function deleteTemplate(templateId: number): Promise<void> {
// --- Actuals helpers --- // --- Actuals helpers ---
export async function getActualTotalsForYear( export async function getActualTotalsForYear(
year: number, year: number
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> { ): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`; const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`; const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo, accountIds); return getActualsByCategoryRange(dateFrom, dateTo);
} }
// --- Budget vs Actual --- // --- Budget vs Actual ---
async function getActualsByCategoryRange( async function getActualsByCategoryRange(
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> { ): Promise<Array<{ category_id: number | null; actual: number }>> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, params.length + 1);
const accountFilter = accountPlaceholders ? ` AND source_id IN (${accountPlaceholders})` : "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<Array<{ category_id: number | null; actual: number }>>( return db.select<Array<{ category_id: number | null; actual: number }>>(
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual `SELECT category_id, COALESCE(SUM(amount), 0) AS actual
FROM transactions FROM transactions
WHERE date BETWEEN $1 AND $2${accountFilter} WHERE date BETWEEN $1 AND $2
GROUP BY category_id`, GROUP BY category_id`,
params [dateFrom, dateTo]
); );
} }
// Income-statement reading order: revenue first, then expenses, then const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
// transfers (Issue #277 — matches COMPARE_TYPE_ORDER / OVER_TIME_TYPE_ORDER).
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/**
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
* (Issue #273) optionally scopes both the month and YTD actuals to a subset of
* import sources (`transactions.source_id`) budgeted amounts are unaffected
* by the filter (a budget is not tied to any account).
*/
export async function getBudgetVsActualData( export async function getBudgetVsActualData(
year: number, year: number,
month: number, month: number
accountIds?: number[]
): Promise<BudgetVsActualRow[]> { ): Promise<BudgetVsActualRow[]> {
// Date ranges // Date ranges
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month); const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
@ -235,8 +219,8 @@ export async function getBudgetVsActualData(
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([ const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualsByCategoryRange(monthFrom, monthTo, accountIds), getActualsByCategoryRange(monthFrom, monthTo),
getActualsByCategoryRange(ytdFrom, ytdTo, accountIds), getActualsByCategoryRange(ytdFrom, ytdTo),
]); ]);
// Build maps // Build maps

View file

@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getExpensesByCategory, deriveNetWorthTile } from "./dashboardService";
import type { SnapshotTotalPoint } from "./balance.service";
vi.mock("./db", () => {
const getDb = vi.fn();
return { getDb };
});
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
describe("getExpensesByCategory — accountIds filter (Issue #273)", () => {
it("without accountIds, the query carries no source_id clause (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31");
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31", []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31", [6, 11]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("t.source_id IN ($3, $4)");
expect(sql).not.toContain("6,11");
expect(params).toEqual(["2025-01-01", "2025-12-31", 6, 11]);
});
it("applies the accountIds filter alone (no dates) alongside the fixed expense-type clause", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory(undefined, undefined, [2]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("t.source_id IN ($1)");
expect(sql).toContain("COALESCE(c.type, 'expense') = 'expense'");
expect(params).toEqual([2]);
});
});
describe("deriveNetWorthTile (Issue #279)", () => {
const totals: SnapshotTotalPoint[] = [
{ snapshot_date: "2026-01-31", total: 1000 },
{ snapshot_date: "2026-02-28", total: 1234.56 },
];
it("is hidden when there is no balance account at all", () => {
expect(deriveNetWorthTile(0, false, totals)).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is hidden when accounts exist but no snapshot has ever been recorded (never a misleading $0)", () => {
expect(deriveNetWorthTile(3, false, [])).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is hidden when accounts exist but no snapshot has ever been recorded, even if totals is non-empty (defensive)", () => {
expect(deriveNetWorthTile(3, false, totals)).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is visible with the latest (last) point once accounts and a snapshot both exist", () => {
expect(deriveNetWorthTile(3, true, totals)).toEqual({
visible: true,
total: 1234.56,
asOfDate: "2026-02-28",
});
});
it("stays hidden if the two signals disagree with an empty totals series (defensive guard)", () => {
expect(deriveNetWorthTile(3, true, [])).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
});

View file

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

View file

@ -469,77 +469,4 @@ describe("getCartesSnapshot", () => {
expect(worst.overrunAbs).toBe(150); expect(worst.overrunAbs).toBe(150);
expect(worst.overrunPct).toBeCloseTo(75, 5); expect(worst.overrunPct).toBeCloseTo(75, 5);
}); });
// --- accountIds filter (Issues #273 / #279) ---
//
// getCartesSnapshot must forward accountIds to EVERY sub-query so the whole
// dashboard honours an active account filter: the two sub-reports that
// support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
// for budget adherence) AND the KPI/sparkline/seasonality series
// (fetchMonthlyFlows, fetchSeasonality). Omitting it anywhere would let the
// dashboard silently mix filtered and unfiltered figures on the same screen.
it("forwards accountIds to the compare (top movers) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const momCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("ORDER BY ABS(month_current_total - month_previous_total) DESC"),
)!;
expect(momCall[0]).toContain("AND t.source_id IN ($9, $10)");
expect(momCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the budget-vs-actual (budget adherence) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions\n WHERE date BETWEEN"),
);
// Both the month and YTD actuals queries (getBudgetVsActualData) receive it.
expect(actualsCalls.length).toBeGreaterThanOrEqual(2);
for (const [sql, params] of actualsCalls) {
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(params as unknown[]).toEqual(expect.arrayContaining([3, 8]));
}
});
it("forwards accountIds to the monthly-flows series (KPIs, sparklines, overlay)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchMonthlyFlows is the only snapshot query selecting a "%Y-%m" month.
const flowCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%Y-%m', date) AS month"),
)!;
expect(flowCall[0]).toContain("AND source_id IN ($3, $4)");
expect(flowCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the seasonality series", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchSeasonality is the only snapshot query filtering on a "%m" month.
const seasonalityCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%m', date) = $1"),
)!;
expect(seasonalityCall[0]).toContain("AND source_id IN ($4, $5)");
expect(seasonalityCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3);
for (const [sql] of mockSelect.mock.calls) {
expect(sql as string).not.toContain("source_id");
}
});
}); });

View file

@ -1,15 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { import {
getMonthlyTrends,
getCategoryOverTime, getCategoryOverTime,
getHighlights, getHighlights,
getCompareMonthOverMonth, getCompareMonthOverMonth,
getCompareYearOverYear, getCompareYearOverYear,
getCategoryZoom, getCategoryZoom,
buildCompareTree, buildCompareTree,
buildOverTimeTree,
} from "./reportService"; } from "./reportService";
import type { CategoryDelta, OverTimeRow } from "../shared/types"; import type { CategoryDelta } from "../shared/types";
// Mock the db module // Mock the db module
vi.mock("./db", () => { vi.mock("./db", () => {
@ -30,66 +28,6 @@ beforeEach(() => {
mockSelect.mockReset(); mockSelect.mockReset();
}); });
describe("getMonthlyTrends", () => {
it("builds query without WHERE clause when no filters are provided (regression: byte-identical to pre-#273)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends();
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("WHERE");
expect(params).toEqual([]);
});
it("applies date range filters without an accountIds argument (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31");
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("date >= $1");
expect(sql).toContain("date <= $2");
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("source_id IN ($3, $4)");
expect(sql).not.toContain("3,7");
expect(params).toEqual(["2025-01-01", "2025-12-31", 3, 7]);
});
it("applies the accountIds filter alone (no dates)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends(undefined, undefined, [5]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("WHERE source_id IN ($1)");
expect(params).toEqual([5]);
});
});
describe("getCategoryOverTime", () => { describe("getCategoryOverTime", () => {
it("builds query without WHERE clause when no filters are provided", async () => { it("builds query without WHERE clause when no filters are provided", async () => {
// First call: top categories, second call: monthly breakdown // First call: top categories, second call: monthly breakdown
@ -148,60 +86,32 @@ describe("getCategoryOverTime", () => {
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]); expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
}); });
it("applies accountIds filter (single id)", async () => { it("applies sourceId filter", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, [3]); await getCategoryOverTime(undefined, undefined, 50, 3);
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id IN ($1)"); expect(topCatSQL).toContain("t.source_id");
expect(topCatParams[0]).toBe(3); expect(topCatParams[0]).toBe(3);
}); });
it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => { it("combines typeFilter, date range, and sourceId", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]); await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense");
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id IN ($1, $2, $3)");
expect(topCatSQL).not.toContain("3,7,12");
// topN ($4) follows the 3 account placeholders.
expect(topCatParams).toEqual([3, 7, 12, 50]);
});
it("an empty accountIds array behaves exactly like no filter (no clause, no orphan param)", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-12-31", 50, []);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).not.toContain("source_id");
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
});
it("combines typeFilter, date range, and accountIds", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, [2], "expense");
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1"); expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
expect(topCatSQL).toContain("t.date >= $2"); expect(topCatSQL).toContain("t.date >= $2");
expect(topCatSQL).toContain("t.date <= $3"); expect(topCatSQL).toContain("t.date <= $3");
expect(topCatSQL).toContain("t.source_id IN ($4)"); expect(topCatSQL).toContain("t.source_id = $4");
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]); expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
}); });
@ -253,66 +163,23 @@ describe("getCategoryOverTime", () => {
expect(result.types).toEqual({ Food: "expense" }); expect(result.types).toEqual({ Food: "expense" });
}); });
it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => { it("groups non-top-N categories into Other (Other left out of the types map)", async () => {
mockSelect mockSelect
// 1. top categories — top-N = 1, so only Food.
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 }, { category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
]) ])
// 2. monthly breakdown — every category (no LIMIT here).
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }, { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 }, { month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 }, { month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
])
// 3. category metadata for the tree.
.mockResolvedValueOnce([
{ id: 1, name: "Food", color: "#ff0000", type: "expense", parent_id: null },
{ id: 2, name: "Transport", color: "#00ff00", type: "expense", parent_id: null },
{ id: 3, name: "Entertainment", color: "#0000ff", type: "expense", parent_id: null },
]); ]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense"); const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
// Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
expect(result.categories).toEqual(["Food", "Other"]); expect(result.categories).toEqual(["Food", "Other"]);
expect(result.colors["Other"]).toBe("#9ca3af"); expect(result.colors["Other"]).toBe("#9ca3af");
expect(result.types).toEqual({ Food: "expense" }); expect(result.types).toEqual({ Food: "expense" });
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 }); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
// Sidecar tree — EVERY category (no top-N, no "Other"), keyed by id.
const treeNames = result.tree.map((r) => r.categoryName);
expect(treeNames).toEqual(expect.arrayContaining(["Food", "Transport", "Entertainment"]));
expect(treeNames).not.toContain("Other");
const food = result.tree.find((r) => r.categoryId === 1)!;
expect(food.monthly).toEqual([300]);
expect(food.total).toBe(300);
expect(food.category_type).toBe("expense");
expect(food.is_parent).toBe(false);
});
it("separates homonym categories of different types in the tree (Issue #264)", async () => {
mockSelect
.mockResolvedValueOnce([]) // top categories (irrelevant to the tree)
.mockResolvedValueOnce([
// Two DIFFERENT categories both named "Divers".
{ month: "2025-01", category_id: 1, category_name: "Divers", total: 1000 },
{ month: "2025-01", category_id: 2, category_name: "Divers", total: 300 },
])
.mockResolvedValueOnce([
{ id: 1, name: "Divers", color: null, type: "income", parent_id: null },
{ id: 2, name: "Divers", color: null, type: "expense", parent_id: null },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31");
// The pivot merges them (single "Divers" cell); the tree keeps them apart.
const incomeDivers = result.tree.find((r) => r.categoryId === 1)!;
const expenseDivers = result.tree.find((r) => r.categoryId === 2)!;
expect(incomeDivers.category_type).toBe("income");
expect(incomeDivers.total).toBe(1000);
expect(expenseDivers.category_type).toBe("expense");
expect(expenseDivers.total).toBe(300);
}); });
}); });
@ -581,46 +448,6 @@ describe("getCompareMonthOverMonth", () => {
expect(result[0].deltaAbs).toBe(50); expect(result[0].deltaAbs).toBe(50);
expect(result[0].cumulativeDeltaAbs).toBe(80); expect(result[0].cumulativeDeltaAbs).toBe(80);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9, $10)");
expect(sql).not.toContain("3,7");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2026-03-01", "2026-03-31",
"2026-01-01", "2026-04-30",
"2026-01-01", "2026-03-31",
3, 7,
]);
});
}); });
describe("getCompareYearOverYear", () => { describe("getCompareYearOverYear", () => {
@ -678,34 +505,6 @@ describe("getCompareYearOverYear", () => {
expect(result[0].deltaPct).toBeCloseTo(50, 4); expect(result[0].deltaPct).toBeCloseTo(50, 4);
expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4); expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4, [9]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9)");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2025-04-01", "2025-04-30",
"2026-01-01", "2026-04-30",
"2025-01-01", "2025-04-30",
9,
]);
});
}); });
describe("real-vs-real compare — transfer netting (Issue #243)", () => { describe("real-vs-real compare — transfer netting (Issue #243)", () => {
@ -1001,114 +800,6 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
}); });
}); });
describe("buildOverTimeTree — hierarchical trends (Issue #264)", () => {
/** Flat id-keyed leaf, mirroring what getCategoryOverTime feeds the builder. */
function otLeaf(categoryId: number | null, categoryName: string, monthly: number[]): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
}
const cats = [
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
] as Parameters<typeof buildOverTimeTree>[1];
it("nests leaves under a parent subtotal equal to the per-month sum of its children", () => {
const rows = buildOverTimeTree(
[otLeaf(22, "Épicerie", [500, 400]), otLeaf(24, "Restaurant", [120, 200])],
cats,
2,
);
// Parent subtotal on top, then children ordered by |total| desc.
expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);
const parent = rows[0];
expect(parent.is_parent).toBe(true);
expect(parent.categoryId).toBe(2);
expect(parent.depth).toBe(0);
expect(parent.category_type).toBe("expense");
// Subtotal series is index-aligned and equals the per-month child sums.
expect(parent.monthly).toEqual([620, 600]);
expect(parent.total).toBe(1220);
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
expect(epicerie.is_parent).toBe(false);
expect(epicerie.depth).toBe(1);
expect(epicerie.parent_id).toBe(2);
expect(epicerie.monthly).toEqual([500, 400]);
});
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
const rows = buildOverTimeTree(
[otLeaf(80, "Paie", [4200]), otLeaf(22, "Épicerie", [500]), otLeaf(50, "REER", [0])],
cats,
1,
);
// Each leaf sits under a parent, so every section yields [subtotal, leaf].
expect(rows.map((r) => r.category_type)).toEqual([
"income",
"income",
"expense",
"expense",
"transfer",
"transfer",
]);
});
it("preserves grand-total invariance vs the flat leaves", () => {
const flat = [
otLeaf(22, "Épicerie", [500, 400]),
otLeaf(24, "Restaurant", [120, 200]),
otLeaf(50, "REER", [0, 0]),
];
const rows = buildOverTimeTree(flat, cats, 2);
const sumLeaves = (rs: OverTimeRow[]) =>
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.total, 0);
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
});
it("surfaces an uncategorized (null id) leaf as a top-level row", () => {
const rows = buildOverTimeTree([otLeaf(null, "Uncategorized", [90])], cats, 1);
expect(rows).toHaveLength(1);
expect(rows[0].categoryName).toBe("Uncategorized");
expect(rows[0].is_parent).toBe(false);
expect(rows[0].depth).toBe(0);
expect(rows[0].monthly).toEqual([90]);
});
it("terminates on a cyclic parent_id chain (A → B → A) without overflowing", () => {
const cyclic = [
{ id: 1, name: "A", color: null, type: "expense", parent_id: 2 },
{ id: 2, name: "B", color: null, type: "expense", parent_id: 1 },
] as Parameters<typeof buildOverTimeTree>[1];
// The relevant-ancestor walk and buildNode both cap at MAX_TREE_DEPTH, so a
// corrupted cycle returns (no root emerges) instead of recursing forever —
// .not.toThrow() catches a "Maximum call stack" RangeError if it overflowed.
let rows: OverTimeRow[] = [];
expect(() => {
rows = buildOverTimeTree([otLeaf(1, "A", [100])], cyclic, 1);
}).not.toThrow();
expect(Array.isArray(rows)).toBe(true);
for (const r of rows) expect(r.depth).toBeLessThanOrEqual(5);
});
});
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => { describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
it("returns parent subtotal rows when category metadata is available", async () => { it("returns parent subtotal rows when category metadata is available", async () => {
mockSelect mockSelect

View file

@ -1,12 +1,10 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService"; import { getBudgetVsActualData } from "./budgetService";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
MonthlyTrendItem, MonthlyTrendItem,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
CategoryOverTimeItem, CategoryOverTimeItem,
OverTimeRow,
HighlightsData, HighlightsData,
HighlightMover, HighlightMover,
CategoryDelta, CategoryDelta,
@ -30,7 +28,7 @@ import type {
export async function getMonthlyTrends( export async function getMonthlyTrends(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
accountIds?: number[], sourceId?: number,
): Promise<MonthlyTrendItem[]> { ): Promise<MonthlyTrendItem[]> {
const db = await getDb(); const db = await getDb();
@ -48,11 +46,10 @@ export async function getMonthlyTrends(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
const accountPlaceholders = inPlaceholders(accountIds, paramIndex); if (sourceId != null) {
if (accountPlaceholders) { whereClauses.push(`source_id = $${paramIndex}`);
whereClauses.push(`source_id IN (${accountPlaceholders})`); params.push(sourceId);
params.push(...accountIds!); paramIndex++;
paramIndex += accountIds!.length;
} }
const whereSQL = const whereSQL =
@ -75,7 +72,7 @@ export async function getCategoryOverTime(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
topN: number = 50, topN: number = 50,
accountIds?: number[], sourceId?: number,
typeFilter?: "expense" | "income" | "transfer", typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> { ): Promise<CategoryOverTimeData> {
const db = await getDb(); const db = await getDb();
@ -100,11 +97,10 @@ export async function getCategoryOverTime(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
const accountPlaceholders = inPlaceholders(accountIds, paramIndex); if (sourceId != null) {
if (accountPlaceholders) { whereClauses.push(`t.source_id = $${paramIndex}`);
whereClauses.push(`t.source_id IN (${accountPlaceholders})`); params.push(sourceId);
params.push(...accountIds!); paramIndex++;
paramIndex += accountIds!.length;
} }
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
@ -187,58 +183,12 @@ export async function getCategoryOverTime(
categories.push("Other"); categories.push("Other");
} }
const data = Array.from(monthMap.values());
// --- Id-keyed hierarchical sidecar (Issue #264) ---
// The pivot above stays byte-identical (top-N + "Other", name-keyed) so the
// chart and dashboard are untouched. The tree is a SEPARATE view of the exact
// same `monthlyRows`, keyed by category_id, carrying EVERY category (no top-N,
// no "Other" bucket) so the trends table + its result lines are exact and two
// homonym categories of different types never collide.
const months = data.map((d) => d.month);
const monthIndex = new Map<string, number>();
months.forEach((m, i) => monthIndex.set(m, i));
// Aggregate every category into an id-keyed per-month magnitude series. A null
// category_id (truly uncategorized) collapses to one bucket; a non-null id that
// has no metadata row (hard-deleted) stays distinct and surfaces as an orphan.
const leafByKey = new Map<number | string, OverTimeRow>();
for (const row of monthlyRows) {
const key = row.category_id ?? "__uncategorized__";
let leaf = leafByKey.get(key);
if (!leaf) {
leaf = {
categoryId: row.category_id,
categoryName: row.category_name,
categoryColor: "#9ca3af",
monthly: new Array(months.length).fill(0),
total: 0,
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
leafByKey.set(key, leaf);
}
const idx = monthIndex.get(row.month);
if (idx != null) {
leaf.monthly[idx] += row.total;
leaf.total += row.total;
}
}
const cats = await db.select<TreeCatMeta[]>(
`SELECT id, name, color, type, parent_id FROM categories`,
);
const tree = buildOverTimeTree(Array.from(leafByKey.values()), cats ?? [], months.length);
return { return {
categories, categories,
data, data: Array.from(monthMap.values()),
colors, colors,
categoryIds, categoryIds,
types, types,
tree,
}; };
} }
@ -485,118 +435,71 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
} }
// --- Generic leaf-driven tree builder (Issue #263) --- // --- Compare hierarchy (Issue #247) ---
/** Section key shared by every hierarchical report (income-statement order). */
export type TreeSectionType = "expense" | "income" | "transfer";
/** /**
* Minimal category metadata every hierarchical report needs: id, display * Minimal category metadata for building the compare tree. Fetched WITHOUT an
* name/color, income-statement `type`, and `parent_id` to walk the hierarchy. * `is_active` filter so soft-deleted categories (is_active = 0) that still carry
* Shared by the compare tree (Issue #247) and the trends tree (Issue #264). * historic transactions keep their place in the hierarchy matching the raw
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
*/ */
export interface TreeCatMeta { interface CompareCatMeta {
id: number; id: number;
name: string; name: string;
color: string | null; color: string | null;
type: TreeSectionType | null; type: "expense" | "income" | "transfer" | null;
parent_id: number | null; parent_id: number | null;
} }
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
// Income statement reading order: revenues first, then expenses, then the
// (netted) transfers — the two result lines are injected around them in the UI.
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */ /** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5; const MAX_TREE_DEPTH = 5;
/** /**
* Injection points that specialise the generic tree builder for a concrete row * Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
* type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape * parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
* emitted leaf/subtotal rows just add the hierarchy fields (`parent_id`, * getBudgetVsActualData.
* `is_parent`, `depth`, ) the caller writes in `makeLeaf` / `makeSubtotal`.
*/
export interface LeafDrivenTreeOptions<T> {
/** Category hierarchy (fetched without an is_active filter — keeps history). */
categories: TreeCatMeta[];
/** Category a leaf belongs to; a null/absent id → orphan (top-level leaf). */
categoryIdOf: (leaf: T) => number | null;
/** Enrich a leaf payload into a leaf row parented at `parentId` / `depth`. */
makeLeaf: (cat: TreeCatMeta, leaf: T, parentId: number | null, depth: number) => T;
/** Reduce a node's descendant leaves into its subtotal (is_parent) row. */
makeSubtotal: (
cat: TreeCatMeta,
descendantLeaves: T[],
parentId: number | null,
depth: number,
) => T;
/** Annotate the "(direct)" leaf of a parent that also has own transactions. */
decorateDirectLeaf: (leafRow: T, cat: TreeCatMeta) => T;
/** Enrich an orphan (Uncategorized / hard-deleted) payload into a root leaf. */
makeOrphan: (leaf: T) => T;
/** Magnitude used to order siblings (descending) — read off a block's head. */
sortKey: (row: T) => number;
/** True when a row is a subtotal, so descendant *leaves* can be summed. */
isSubtotal: (row: T) => boolean;
/** Section key of a row for the final contiguous type sort. */
sectionOf: (row: T) => string;
/** Section order, income-first e.g. `{ income: 0, expense: 1, transfer: 2 }`. */
sectionOrder: Record<string, number>;
/** Rank for a section absent from `sectionOrder` (default 9 — sorts last). */
unknownSectionRank?: number;
/** Cyclic-chain depth cap (default `MAX_TREE_DEPTH`). */
maxDepth?: number;
}
/**
* Generic leaf-driven hierarchy builder. Turns a flat list of per-category
* leaves into a parent/child tree with subtotal rows, ordered by magnitude
* within each type and grouped into contiguous income expense transfer
* sections. Driven by the leaves that actually exist (not the full category
* list), so netted-to-zero groups and soft-deleted categories with history are
* preserved and no empty rows appear.
* *
* Behavior including the `MAX_TREE_DEPTH` guard and buildNode loop-break * The #243 transfer netting is untouched: leaf values are exactly what the SQL
* against a corrupted parent_id cycle is shared verbatim by every * returned, and a subtotal is the arithmetic sum of its descendant leaves so a
* hierarchical report; the row arithmetic is injected via `opts`. Extracted * group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
* from `buildCompareTree` (Issue #263) so the trends tree (Issue #264) reuses * net. Driven by `leaves` (the categories that actually had transactions in the
* the exact same skeleton. * window) rather than the full category list, so netted-to-zero transfers and
* soft-deleted categories with history are preserved and no empty rows appear.
*
* Exported for unit testing.
*/ */
export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<T>): T[] { export function buildCompareTree(
const { leaves: CategoryDelta[],
categories, categories: CompareCatMeta[],
categoryIdOf, ): CategoryDelta[] {
makeLeaf, const catById = new Map<number, CompareCatMeta>();
makeSubtotal,
decorateDirectLeaf,
makeOrphan,
sortKey,
isSubtotal,
sectionOf,
sectionOrder,
unknownSectionRank = 9,
maxDepth = MAX_TREE_DEPTH,
} = opts;
const catById = new Map<number, TreeCatMeta>();
for (const c of categories) catById.set(c.id, c); for (const c of categories) catById.set(c.id, c);
// Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or // Flat delta lookup by category id. Rows whose category id is null
// absent from the table (hard-deleted) become orphans: depth-0 leaves // (Uncategorized) or absent from the table (hard-deleted) become orphans:
// preserved in their incoming order. // depth-0 leaves preserved in their incoming order.
const leafByCat = new Map<number, T>(); const deltaByCat = new Map<number, CategoryDelta>();
const orphans: T[] = []; const orphans: CategoryDelta[] = [];
for (const l of leaves) { for (const d of leaves) {
const id = categoryIdOf(l); if (d.categoryId != null && catById.has(d.categoryId)) {
if (id != null && catById.has(id)) { deltaByCat.set(d.categoryId, d);
leafByCat.set(id, l);
} else { } else {
orphans.push(l); orphans.push(d);
} }
} }
// "Relevant" = every category that has a leaf plus all of its ancestors. // "Relevant" = every category that has a delta plus all of its ancestors.
const relevant = new Set<number>(); const relevant = new Set<number>();
for (const id of leafByCat.keys()) { for (const id of deltaByCat.keys()) {
let cur: number | null | undefined = id; let cur: number | null | undefined = id;
let guard = 0; let guard = 0;
while (cur != null && guard <= maxDepth) { while (cur != null && guard <= MAX_TREE_DEPTH) {
if (relevant.has(cur)) break; if (relevant.has(cur)) break;
relevant.add(cur); relevant.add(cur);
cur = catById.get(cur)?.parent_id ?? null; cur = catById.get(cur)?.parent_id ?? null;
@ -605,7 +508,7 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
} }
// Adjacency among relevant categories only, preserving DB order. // Adjacency among relevant categories only, preserving DB order.
const childrenByParent = new Map<number, TreeCatMeta[]>(); const childrenByParent = new Map<number, CompareCatMeta[]>();
for (const c of categories) { for (const c of categories) {
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) { if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
let arr = childrenByParent.get(c.parent_id); let arr = childrenByParent.get(c.parent_id);
@ -614,36 +517,93 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
} }
} }
const typeOf = (c: CompareCatMeta): "expense" | "income" | "transfer" => c.type ?? "expense";
const leafRow = (
cat: CompareCatMeta,
parentId: number | null,
depth: number,
): CategoryDelta => ({
...deltaByCat.get(cat.id)!,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
});
const subtotalRow = (
cat: CompareCatMeta,
descendantLeaves: CategoryDelta[],
parentId: number | null,
depth: number,
): CategoryDelta => {
let previousAmount = 0;
let currentAmount = 0;
let cumulativePreviousAmount = 0;
let cumulativeCurrentAmount = 0;
for (const l of descendantLeaves) {
previousAmount += l.previousAmount;
currentAmount += l.currentAmount;
cumulativePreviousAmount += l.cumulativePreviousAmount;
cumulativeCurrentAmount += l.cumulativeCurrentAmount;
}
const deltaAbs = currentAmount - previousAmount;
const cumulativeDeltaAbs = cumulativeCurrentAmount - cumulativePreviousAmount;
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
previousAmount,
currentAmount,
deltaAbs,
// Match rowsToDeltas' leaf formula (signed denominator) so a single-child
// parent shows the same % as its child.
deltaPct: previousAmount !== 0 ? (deltaAbs / previousAmount) * 100 : null,
cumulativePreviousAmount,
cumulativeCurrentAmount,
cumulativeDeltaAbs,
cumulativeDeltaPct:
cumulativePreviousAmount !== 0
? (cumulativeDeltaAbs / cumulativePreviousAmount) * 100
: null,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
};
interface Block { interface Block {
rows: T[]; rows: CategoryDelta[];
sortKey: number; // magnitude of the block head — orders siblings sortKey: number; // |monthly delta| of the block head — orders siblings
} }
// Builds a node's block: a pure leaf, or a subtotal followed by its // Builds a node's block: a pure leaf, or a subtotal followed by its
// (recursively built) child blocks, siblings ordered by magnitude desc. // (recursively built) child blocks, siblings ordered by |monthly delta| desc.
const buildNode = (cat: TreeCatMeta, depth: number): Block | null => { const buildNode = (cat: CompareCatMeta, depth: number): Block | null => {
// Stop descending past the depth cap so a corrupted parent_id cycle can // Stop descending past the depth cap so a corrupted parent_id cycle can
// never recurse forever — deeper nodes collapse to leaves (real category // never recurse forever — deeper nodes collapse to leaves (real category
// trees are ≤ 3 levels). // trees are ≤ 3 levels).
const children = depth >= maxDepth ? [] : (childrenByParent.get(cat.id) ?? []); const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []);
const hasDirect = leafByCat.has(cat.id); const hasDirect = deltaByCat.has(cat.id);
if (children.length === 0) { if (children.length === 0) {
if (!hasDirect) return null; if (!hasDirect) return null;
const leaf = makeLeaf(cat, leafByCat.get(cat.id)!, cat.parent_id ?? null, depth); const leaf = leafRow(cat, cat.parent_id ?? null, depth);
return { rows: [leaf], sortKey: sortKey(leaf) }; return { rows: [leaf], sortKey: Math.abs(leaf.deltaAbs) };
} }
const childBlocks: Block[] = []; const childBlocks: Block[] = [];
// A category with both children AND its own transactions surfaces the direct // A category with both children AND its own transactions surfaces the direct
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible // spend as a "(direct)" leaf so the subtotal stays the sum of its visible
// rows. Rare: a parent auto-loses is_inputable when a child is added. // rows (mirrors getBudgetVsActualData). Rare: a parent auto-loses
// is_inputable when a child is added.
if (hasDirect) { if (hasDirect) {
const direct = decorateDirectLeaf( const direct = leafRow(cat, cat.id, depth + 1);
makeLeaf(cat, leafByCat.get(cat.id)!, cat.id, depth + 1), childBlocks.push({
cat, rows: [{ ...direct, categoryName: `${cat.name} (direct)` }],
); sortKey: Math.abs(direct.deltaAbs),
childBlocks.push({ rows: [direct], sortKey: sortKey(direct) }); });
} }
for (const child of children) { for (const child of children) {
const b = buildNode(child, depth + 1); const b = buildNode(child, depth + 1);
@ -652,9 +612,9 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
childBlocks.sort((a, b) => b.sortKey - a.sortKey); childBlocks.sort((a, b) => b.sortKey - a.sortKey);
const childRows = childBlocks.flatMap((b) => b.rows); const childRows = childBlocks.flatMap((b) => b.rows);
const descendantLeaves = childRows.filter((r) => !isSubtotal(r)); const descendantLeaves = childRows.filter((r) => !r.is_parent);
const subtotal = makeSubtotal(cat, descendantLeaves, cat.parent_id ?? null, depth); const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth);
return { rows: [subtotal, ...childRows], sortKey: sortKey(subtotal) }; return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) };
}; };
// Roots = relevant categories with no relevant parent. Ordered by magnitude. // Roots = relevant categories with no relevant parent. Ordered by magnitude.
@ -669,15 +629,17 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
const rows = rootBlocks.flatMap((b) => b.rows); const rows = rootBlocks.flatMap((b) => b.rows);
// Orphans (Uncategorized + hard-deleted) appended in their incoming order. // Orphans (Uncategorized + hard-deleted) appended in their incoming order.
for (const l of orphans) rows.push(makeOrphan(l)); for (const d of orphans) {
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
}
// Stable sort by section so types (income → expense → transfer) are // Stable sort by type so sections (income → expense → transfer) are
// contiguous; magnitude/tree order within a type is preserved via the index. // contiguous; magnitude/tree order within a type is preserved via the index.
const order = new Map<T, number>(); const order = new Map<CategoryDelta, number>();
rows.forEach((r, i) => order.set(r, i)); rows.forEach((r, i) => order.set(r, i));
rows.sort((a, b) => { rows.sort((a, b) => {
const ta = sectionOrder[sectionOf(a)] ?? unknownSectionRank; const ta = COMPARE_TYPE_ORDER[a.category_type ?? "expense"] ?? 9;
const tb = sectionOrder[sectionOf(b)] ?? unknownSectionRank; const tb = COMPARE_TYPE_ORDER[b.category_type ?? "expense"] ?? 9;
if (ta !== tb) return ta - tb; if (ta !== tb) return ta - tb;
return order.get(a)! - order.get(b)!; return order.get(a)! - order.get(b)!;
}); });
@ -685,182 +647,6 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
return rows; return rows;
} }
// --- Compare hierarchy (Issue #247) ---
/**
* Minimal category metadata for building the compare tree. Fetched WITHOUT an
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry
* historic transactions keep their place in the hierarchy matching the raw
* LEFT JOIN behavior of compareDeltaSql() (no regression on the leaves shown).
*/
type CompareCatMeta = TreeCatMeta;
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
// Income statement reading order: revenues first, then expenses, then the
// (netted) transfers — the two result lines are injected around them in the UI.
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/**
* Turns the flat per-category deltas returned by compareDeltaSql() into a
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
* getBudgetVsActualData.
*
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
* returned, and a subtotal is the arithmetic sum of its descendant leaves so a
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
* net.
*
* A thin specialisation of `buildLeafDrivenTree` (Issue #263): the generic
* skeleton drives the hierarchy walk, sibling ordering, depth guard and section
* sort; this only supplies the delta arithmetic. Behavior is byte-identical to
* the previous hand-rolled builder the compare tests are the guard.
*
* Exported for unit testing.
*/
export function buildCompareTree(
leaves: CategoryDelta[],
categories: CompareCatMeta[],
): CategoryDelta[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
return buildLeafDrivenTree<CategoryDelta>(leaves, {
categories,
categoryIdOf: (d) => d.categoryId,
makeLeaf: (cat, d, parentId, depth) => ({
...d,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
let previousAmount = 0;
let currentAmount = 0;
let cumulativePreviousAmount = 0;
let cumulativeCurrentAmount = 0;
for (const l of descendantLeaves) {
previousAmount += l.previousAmount;
currentAmount += l.currentAmount;
cumulativePreviousAmount += l.cumulativePreviousAmount;
cumulativeCurrentAmount += l.cumulativeCurrentAmount;
}
const deltaAbs = currentAmount - previousAmount;
const cumulativeDeltaAbs = cumulativeCurrentAmount - cumulativePreviousAmount;
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
previousAmount,
currentAmount,
deltaAbs,
// Match rowsToDeltas' leaf formula (signed denominator) so a single-child
// parent shows the same % as its child.
deltaPct: previousAmount !== 0 ? (deltaAbs / previousAmount) * 100 : null,
cumulativePreviousAmount,
cumulativeCurrentAmount,
cumulativeDeltaAbs,
cumulativeDeltaPct:
cumulativePreviousAmount !== 0
? (cumulativeDeltaAbs / cumulativePreviousAmount) * 100
: null,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
},
// A parent with both children and its own transactions surfaces the direct
// spend as a "(direct)" leaf (mirrors getBudgetVsActualData).
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
// Orphans keep their own already-computed delta; only the hierarchy fields
// are stamped. Uncategorized rows (category_type undefined) → 'expense'.
makeOrphan: (d) => ({
...d,
parent_id: null,
is_parent: false,
depth: 0,
category_type: d.category_type ?? "expense",
}),
sortKey: (row) => Math.abs(row.deltaAbs),
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
});
}
// --- Trends hierarchy (Issue #264) ---
/**
* Turns the flat per-category monthly leaves of `getCategoryOverTime` into a
* parent/child tree with subtotal (`is_parent`) rows the id-keyed sidecar to
* the name-keyed pivot. Another thin specialisation of `buildLeafDrivenTree`
* (Issue #263): the generic skeleton drives the hierarchy walk, sibling
* ordering, `MAX_TREE_DEPTH` cycle guard and the income expense transfer
* section sort (reusing `COMPARE_TYPE_ORDER`, income-first since #253); this only
* supplies the per-month magnitude arithmetic. A subtotal's `monthly[i]` is the
* sum of its descendant leaves' `monthly[i]`, so section subtotals and the
* result lines can be read straight off the leaves. `monthCount` fixes the
* length of every subtotal's series so it stays index-aligned with the pivot.
*
* Exported for unit testing.
*/
export function buildOverTimeTree(
leaves: OverTimeRow[],
categories: TreeCatMeta[],
monthCount: number,
): OverTimeRow[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
return buildLeafDrivenTree<OverTimeRow>(leaves, {
categories,
categoryIdOf: (l) => l.categoryId,
makeLeaf: (cat, l, parentId, depth) => ({
...l,
categoryColor: cat.color ?? l.categoryColor,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
const monthly = new Array<number>(monthCount).fill(0);
let total = 0;
for (const dl of descendantLeaves) {
for (let i = 0; i < monthCount; i++) monthly[i] += dl.monthly[i] ?? 0;
total += dl.total;
}
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
monthly,
total,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
},
// A parent with both children and its own transactions surfaces the direct
// spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData).
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
// Orphans (Uncategorized / hard-deleted) keep their own series; only the
// hierarchy fields are stamped. No metadata → default 'expense'.
makeOrphan: (l) => ({
...l,
parent_id: null,
is_parent: false,
depth: 0,
category_type: l.category_type ?? "expense",
}),
sortKey: (row) => Math.abs(row.total),
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
});
}
function previousMonth(year: number, month: number): { year: number; month: number } { function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 }; if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 }; return { year, month: month - 1 };
@ -886,16 +672,8 @@ function previousMonth(year: number, month: number): { year: number; month: numb
* rows still contribute only their outflows, byte-identical to before. The two * rows still contribute only their outflows, byte-identical to before. The two
* result lines (before/after transfers) are computed in ComparePeriodTable from * result lines (before/after transfers) are computed in ComparePeriodTable from
* the per-type subtotals, not here. Uncategorized rows (c.type NULL) 'expense'. * the per-type subtotals, not here. Uncategorized rows (c.type NULL) 'expense'.
*
* `accountIds` (Issue #273) adds an optional `t.source_id IN (...)` clause
* one bound placeholder per id, starting at $9 (the 8 date bounds occupy
* $1-$8). Omitted/empty no clause at all, so the query text and results stay
* byte-identical to the pre-#273 behavior.
*/ */
function compareDeltaSql(accountIds?: number[]): string { const COMPARE_DELTA_SQL = `SELECT
const accountPlaceholders = inPlaceholders(accountIds, 9);
const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
return `SELECT
t.category_id, t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(c.color, '#9ca3af') AS category_color,
@ -911,23 +689,20 @@ function compareDeltaSql(accountIds?: number[]): string {
OR (t.date >= $3 AND t.date <= $4) OR (t.date >= $3 AND t.date <= $4)
OR (t.date >= $5 AND t.date <= $6) OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8) OR (t.date >= $7 AND t.date <= $8)
)${accountFilter} )
GROUP BY t.category_id, category_name, category_color GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`; ORDER BY ABS(month_current_total - month_previous_total) DESC`;
}
/** /**
* Month-over-month expense delta by category. Returns both a monthly view * Month-over-month expense delta by category. Returns both a monthly view
* (reference month vs immediately-previous month) and a cumulative YTD view * (reference month vs immediately-previous month) and a cumulative YTD view
* (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative * (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative
* progress through end of last month" vs "cumulative progress through end of * progress through end of last month" vs "cumulative progress through end of
* this month"). All SQL parameterised. `accountIds` (Issue #273) optionally * this month"). All SQL parameterised.
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareMonthOverMonth( export async function getCompareMonthOverMonth(
year: number, year: number,
month: number, month: number,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curStart, end: curEnd } = monthBoundaries(year, month); const { start: curStart, end: curEnd } = monthBoundaries(year, month);
@ -945,19 +720,16 @@ export async function getCompareMonthOverMonth(
const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd; const cumPreviousEnd = prevEnd;
const deltaParams: unknown[] = [
curStart, curEnd,
prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the // Delta select stays first so its params/SQL remain `mock.calls[0]`; the
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards // category metadata (for the hierarchy) is fetched alongside. `?? []` guards
// under-specified mocks — db.select never returns undefined in production. // under-specified mocks — db.select never returns undefined in production.
const [rows, cats] = await Promise.all([ const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams), db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
curStart, curEnd,
prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
]),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL), db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -969,13 +741,11 @@ export async function getCompareMonthOverMonth(
* view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the * view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the
* reference year's December as the "current month" when no explicit * reference year's December as the "current month" when no explicit
* reference month is provided; callers typically pass the user's chosen * reference month is provided; callers typically pass the user's chosen
* reference month. All SQL parameterised. `accountIds` (Issue #273) optionally * reference month. All SQL parameterised.
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareYearOverYear( export async function getCompareYearOverYear(
year: number, year: number,
month: number = 12, month: number = 12,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month); const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
@ -986,17 +756,14 @@ export async function getCompareYearOverYear(
const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd; const cumPreviousEnd = prevMonthEnd;
const deltaParams: unknown[] = [
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// See getCompareMonthOverMonth: delta select first, categories alongside. // See getCompareMonthOverMonth: delta select first, categories alongside.
const [rows, cats] = await Promise.all([ const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams), db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
]),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL), db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -1186,25 +953,18 @@ interface RawMonthFlow {
async function fetchMonthlyFlows( async function fetchMonthlyFlows(
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string,
accountIds?: number[],
): Promise<RawMonthFlow[]> { ): Promise<RawMonthFlow[]> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, 3);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawMonthFlow[]>( return db.select<RawMonthFlow[]>(
`SELECT `SELECT
strftime('%Y-%m', date) AS month, strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions FROM transactions
WHERE date >= $1 AND date <= $2${accountClause} WHERE date >= $1 AND date <= $2
GROUP BY month GROUP BY month
ORDER BY month ASC`, ORDER BY month ASC`,
params, [dateFrom, dateTo],
); );
} }
@ -1217,16 +977,9 @@ async function fetchSeasonality(
month: number, month: number,
yearFrom: number, yearFrom: number,
yearTo: number, yearTo: number,
accountIds?: number[],
): Promise<RawSeasonalityRow[]> { ): Promise<RawSeasonalityRow[]> {
const db = await getDb(); const db = await getDb();
const mm = String(month).padStart(2, "0"); const mm = String(month).padStart(2, "0");
const params: unknown[] = [mm, yearFrom, yearTo];
const accountPlaceholders = inPlaceholders(accountIds, 4);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawSeasonalityRow[]>( return db.select<RawSeasonalityRow[]>(
`SELECT `SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year, CAST(strftime('%Y', date) AS INTEGER) AS year,
@ -1234,10 +987,10 @@ async function fetchSeasonality(
FROM transactions FROM transactions
WHERE strftime('%m', date) = $1 WHERE strftime('%m', date) = $1
AND CAST(strftime('%Y', date) AS INTEGER) >= $2 AND CAST(strftime('%Y', date) AS INTEGER) >= $2
AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause} AND CAST(strftime('%Y', date) AS INTEGER) <= $3
GROUP BY year GROUP BY year
ORDER BY year DESC`, ORDER BY year DESC`,
params, [mm, yearFrom, yearTo],
); );
} }
@ -1253,19 +1006,11 @@ async function fetchSeasonality(
* via the monthly series instead of re-querying. * via the monthly series instead of re-querying.
* 4. Budget vs actual for the reference month. * 4. Budget vs actual for the reference month.
* 5. Seasonality: same calendar month across the two prior years. * 5. Seasonality: same calendar month across the two prior years.
*
* `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
* dashboard honours an active account filter: the top-movers
* (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
* and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
* `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
* filtered and unfiltered figures on the same screen.
*/ */
export async function getCartesSnapshot( export async function getCartesSnapshot(
referenceYear: number, referenceYear: number,
referenceMonth: number, referenceMonth: number,
mode: CartesKpiPeriodMode = "month", mode: CartesKpiPeriodMode = "month",
accountIds?: number[],
): Promise<CartesSnapshot> { ): Promise<CartesSnapshot> {
// Date window: 25 months back from the reference to cover YoY + a 13-month // Date window: 25 months back from the reference to cover YoY + a 13-month
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24. // sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
@ -1275,10 +1020,10 @@ export async function getCartesSnapshot(
// Seasonality range: previous 2 years for the same calendar month. // Seasonality range: previous 2 years for the same calendar month.
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([ const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds), fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
fetchMonthlyFlows(windowStartIso, refEnd, accountIds), fetchMonthlyFlows(windowStartIso, refEnd),
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds), getCompareMonthOverMonth(referenceYear, referenceMonth),
getBudgetVsActualData(referenceYear, referenceMonth, accountIds), getBudgetVsActualData(referenceYear, referenceMonth),
]); ]);
// Index the flow rows by month for O(1) lookup, then fill missing months // Index the flow rows by month for O(1) lookup, then fill missing months
@ -1432,7 +1177,7 @@ export async function getCartesSnapshot(
// Top movers: biggest MoM increases / decreases. `momRows` now carries the // Top movers: biggest MoM increases / decreases. `momRows` now carries the
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. Keep only EXPENSE // parent group can't double-count against its own leaves. Keep only EXPENSE
// leaves: since Issue #253 broadened compareDeltaSql() to surface income (and // leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and
// net transfers), momRows now also carries revenue rows — and this card is a // net transfers), momRows now also carries revenue rows — and this card is a
// spending view whose colours read "up = red" (more spending is bad), so a // spending view whose colours read "up = red" (more spending is bad), so a
// salary rise must not appear under "biggest increases" in red. Mirror the // salary rise must not appear under "biggest increases" in red. Mirror the

View file

@ -285,17 +285,6 @@ export interface RecentTransaction {
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual"; export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
/**
* Shared filter state for the report pages: the active period plus the
* account (import source) subset. `accountIds` is a list of `ImportSource.id`
* not to be confused with the Bilan module's `balance_accounts` ids, a
* disjoint id space. An empty list means "no filter" (all accounts).
*/
export interface ReportFilters {
period: DashboardPeriod;
accountIds: number[];
}
export interface CategoryDelta { export interface CategoryDelta {
categoryId: number | null; categoryId: number | null;
categoryName: string; categoryName: string;
@ -378,31 +367,6 @@ export interface CategoryOverTimeItem {
[categoryName: string]: number | string; [categoryName: string]: number | string;
} }
/**
* One row of the id-keyed trends tree (Issue #264) the sidecar to the
* name-keyed pivot below. Carries a per-month magnitude series instead of a
* single delta, but mirrors `CategoryDelta`'s snake_case hierarchy block
* (`parent_id` / `is_parent` / `depth` / `category_type`) so it composes with
* `collapsibleRows` / `useCollapsibleGroups` without friction. Section/type is
* resolved by `category_id` (never by name), so two homonym categories of
* different types never collide the fix the name-keyed pivot cannot express.
*/
export interface OverTimeRow {
categoryId: number | null;
categoryName: string;
categoryColor: string;
/** Positive magnitude per month, index-aligned to `CategoryOverTimeData.data`. */
monthly: number[];
/** Sum of `monthly` — sibling ordering + row total convenience. */
total: number;
// Hierarchy block — same snake_case names as CategoryDelta (Issue #247/#264).
// `is_parent` rows are subtotals whose series = the sum of their leaf subtree.
parent_id: number | null;
is_parent: boolean;
depth: number;
category_type: "expense" | "income" | "transfer";
}
export interface CategoryOverTimeData { export interface CategoryOverTimeData {
categories: string[]; categories: string[];
data: CategoryOverTimeItem[]; data: CategoryOverTimeItem[];
@ -410,13 +374,6 @@ export interface CategoryOverTimeData {
categoryIds: Record<string, number | null>; categoryIds: Record<string, number | null>;
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */ /** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
types: Record<string, "expense" | "income" | "transfer">; types: Record<string, "expense" | "income" | "transfer">;
/**
* Id-keyed hierarchical sidecar (Issue #264): EVERY category (no top-N, no
* "Other" bucket), parent-first depth-first, income expense transfer.
* Drives the trends table + its result lines. The pivot fields above are left
* unchanged and still drive the (top-N-capped) chart and the dashboard.
*/
tree: OverTimeRow[];
} }
export interface BudgetVsActualRow { export interface BudgetVsActualRow {

View file

@ -1,116 +0,0 @@
import { describe, it, expect } from "vitest";
import {
type CollapseAccessors,
collapsibleKeys,
parseStoredExpanded,
serializeExpanded,
visibleRows,
} from "./collapsibleRows";
/** Tiny hierarchy row for the tests: key, depth, is-parent. */
interface Row {
k: string;
d: number;
p: boolean;
}
const acc: CollapseAccessors<Row> = {
keyOf: (r) => r.k,
depthOf: (r) => r.d,
isParent: (r) => r.p,
};
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p });
// A two-group tree with one nested sub-group:
// Housing (parent, d0)
// Rent (leaf, d1)
// Utilities (parent, d1) <- intermediate parent
// Power (leaf, d2)
// Salary (parent, d0)
// Paycheck (leaf, d1)
// Misc (leaf, d0) <- top-level leaf, not a group
const tree: Row[] = [
row("housing", 0, true),
row("rent", 1, false),
row("utilities", 1, true),
row("power", 2, false),
row("salary", 0, true),
row("paycheck", 1, false),
row("misc", 0, false),
];
// Default = collapsed: a group is collapsed unless its key is in the expanded set.
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k);
describe("collapsibleRows.visibleRows", () => {
it("collapses every group by default (empty expanded set)", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
// Only depth-0 rows survive: the two group headers + the top-level leaf.
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "misc"]);
});
it("reveals a group's full subtree (incl. nested sub-groups) when expanded", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"])));
expect(visible.map((r) => r.k)).toEqual([
"housing",
"rent",
"utilities",
"power",
"salary",
"misc",
]);
});
it("keeps groups independent — expanding one leaves the others collapsed", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"])));
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]);
});
it("shows everything when all groups are expanded", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
expect(visible).toHaveLength(tree.length);
});
it("always keeps a top-level leaf that has no group", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
expect(visible.some((r) => r.k === "misc")).toBe(true);
});
});
describe("collapsibleRows.collapsibleKeys", () => {
it("lists only top-level parent rows, in order", () => {
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
});
it("returns [] when there is no hierarchy", () => {
const flat = [row("a", 0, false), row("b", 0, false)];
expect(collapsibleKeys(flat, acc)).toEqual([]);
});
});
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
it("treats absent storage as the collapsed default (empty set)", () => {
expect(parseStoredExpanded(null).size).toBe(0);
expect(parseStoredExpanded("").size).toBe(0);
});
it("parses a JSON string array back into a set", () => {
expect([...parseStoredExpanded('["housing","salary"]')].sort()).toEqual(["housing", "salary"]);
});
it("falls back to empty on corrupt or non-array JSON", () => {
expect(parseStoredExpanded("{not json").size).toBe(0);
expect(parseStoredExpanded('{"a":1}').size).toBe(0);
expect(parseStoredExpanded("42").size).toBe(0);
});
it("drops non-string members defensively", () => {
expect([...parseStoredExpanded('["ok", 3, null, "yes"]')].sort()).toEqual(["ok", "yes"]);
});
it("round-trips through serialize", () => {
const set = new Set(["a", "b"]);
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
});
});

View file

@ -1,86 +0,0 @@
/**
* Collapse/expand of top-level category groups in the hierarchical comparable
* reports (real-vs-real Compare + Budget-vs-Actual) issue #254.
*
* Pure helpers only; the React state + persistence glue lives in the
* `useCollapsibleGroups` hook.
*
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps
* its own subtotal row visible but hides its entire subtree (every following
* depth 1 row until the next depth-0 row). Rows must arrive in depth-first
* order the order the compare/budget services already emit them in, and the
* same order `reorderRows` relies on.
*
* The accessors are passed in rather than read off fixed field names so each
* table can supply the *exact* depth expression it renders with (CategoryDelta
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from
* `parent_id`). Keeping the collapse depth and the indentation depth identical
* guarantees the hidden rows are exactly the indented descendants.
*/
/** How the collapse logic reads hierarchy position + identity off a row. */
export interface CollapseAccessors<T> {
/** Stable per-group key (a category id, stringified). */
keyOf: (row: T) => string;
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
depthOf: (row: T) => number;
/** True when the row is a group header / subtotal (has children). */
isParent: (row: T) => boolean;
}
/**
* Keeps every row that is currently visible given which top-level groups are
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a
* collapsed one keeps its own (subtotal) row and drops its whole subtree.
*/
export function visibleRows<T>(
rows: T[],
acc: CollapseAccessors<T>,
isCollapsed: (row: T) => boolean,
): T[] {
const out: T[] = [];
// While true we are inside a collapsed top-level parent's subtree and drop
// every deeper row until the next depth-0 row re-opens the flow.
let hidingSubtree = false;
for (const row of rows) {
if (acc.depthOf(row) === 0) {
out.push(row);
hidingSubtree = acc.isParent(row) && isCollapsed(row);
} else if (!hidingSubtree) {
out.push(row);
}
}
return out;
}
/** Keys of every collapsible group (top-level parent rows), in encounter order. */
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
const keys: string[] = [];
for (const row of rows) {
if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row));
}
return keys;
}
/**
* Parses the persisted value into the set of *expanded* group keys. The report
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt
* value yields an empty set i.e. all groups collapsed on a first-ever visit.
*/
export function parseStoredExpanded(raw: string | null): Set<string> {
if (!raw) return new Set();
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((k): k is string => typeof k === "string"));
}
} catch {
// Corrupt value: fall back to the collapsed default.
}
return new Set();
}
/** Serialises the set of expanded group keys for persistence. */
export function serializeExpanded(expanded: Set<string>): string {
return JSON.stringify([...expanded]);
}

View file

@ -1,27 +0,0 @@
import { describe, it, expect } from "vitest";
import { inPlaceholders } from "./sqlFilters";
describe("inPlaceholders", () => {
it("returns null for undefined ids (no filter)", () => {
expect(inPlaceholders(undefined, 1)).toBeNull();
});
it("returns null for an empty array (no filter)", () => {
expect(inPlaceholders([], 1)).toBeNull();
});
it("builds a single placeholder for one id", () => {
expect(inPlaceholders([7], 1)).toBe("$1");
});
it("builds one placeholder per id, starting at startIndex", () => {
expect(inPlaceholders([3, 7, 12], 4)).toBe("$4, $5, $6");
});
it("never joins the raw id values into the string (CWE-89 guard)", () => {
const result = inPlaceholders([3, 7], 1);
expect(result).not.toContain("3");
expect(result).not.toContain("7");
expect(result).toBe("$1, $2");
});
});

View file

@ -1,16 +0,0 @@
/**
* Shared helper for building a parameterized SQL `IN (...)` clause one
* bound placeholder per id, never a joined/interpolated value list (CWE-89).
* First introduced for the report services' optional account
* (`transactions.source_id`) filter (Issue #273); reused by every service
* that accepts an `accountIds?: number[]` filter so the placeholder
* bookkeeping (start index, one `$N` per id) lives in exactly one place.
*
* Returns `null` when `ids` is empty/undefined so callers can skip adding the
* clause entirely that is the "no filter" case, and the query must stay
* byte-identical to how it read before the filter existed.
*/
export function inPlaceholders(ids: number[] | undefined, startIndex: number): string | null {
if (!ids || ids.length === 0) return null;
return ids.map((_, i) => `$${startIndex + i}`).join(", ");
}