Compare commits
63 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a88e32a06 | ||
|
|
0ae7ea0887 | ||
|
|
05d76205a0 | ||
|
|
a982f9ed9d | ||
|
|
6dbaaa25fe | ||
|
|
e55c3bd250 | ||
|
|
9ee5ad353f | ||
|
|
093ba83c51 | ||
|
|
193ef1d9dd | ||
|
|
14755eb155 | ||
|
|
ab67c68605 | ||
|
|
26896bbf03 | ||
|
|
fe9ae0118c | ||
|
|
30682004d3 | ||
|
|
70b2cd2e42 | ||
|
|
b40381fb89 | ||
|
|
4a93c60ea7 | ||
|
|
65be6c7482 | ||
|
|
a8e3775b8b | ||
|
|
b956091138 | ||
|
|
fe87313ba3 | ||
|
|
5b94c154f7 | ||
|
|
36abad7a2e | ||
|
|
19cdad2718 | ||
|
|
5a1ce42034 | ||
|
|
64ae00a847 | ||
|
|
5ade8650cf | ||
|
|
192c0078a4 | ||
| 0a55a47ab5 | |||
|
|
616cccec37 | ||
|
|
ababa3c010 | ||
|
|
36385320a6 | ||
|
|
f3ccb115ae | ||
|
|
450229522b | ||
|
|
44976767d1 | ||
|
|
1360940336 | ||
|
|
82040abc74 | ||
|
|
c7914c2ca5 | ||
|
|
d57b2563af | ||
|
|
f45845e408 | ||
|
|
51531ec0ce | ||
|
|
206f89e1b1 | ||
|
|
9124c888de | ||
|
|
2a3cc07a0e | ||
|
|
ead62943fe | ||
|
|
8bd51214c1 | ||
|
|
a6fa89e041 | ||
|
|
4f87ce329c | ||
|
|
ce2793fc2c | ||
|
|
e45736bbde | ||
|
|
c8c765e2f0 | ||
|
|
82550d6d38 | ||
|
|
f3e8e94b16 | ||
|
|
4b000d022f | ||
|
|
110ad57e1b | ||
| 9e31f0ee5b | |||
|
|
54ef0f0229 | ||
|
|
4dc3baaa9b | ||
| 9e9ffa088a | |||
|
|
23b574ebcb | ||
| 6aa4f66242 | |||
|
|
713667c33d | ||
|
|
746d5ee3ab |
75 changed files with 7322 additions and 1355 deletions
|
|
@ -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-04-19
|
updated: 2026-07-01
|
||||||
---
|
---
|
||||||
|
|
||||||
# /release — Release Simpl-Resultat
|
# /release — Release Simpl-Resultat
|
||||||
|
|
@ -24,9 +24,9 @@ updated: 2026-04-19
|
||||||
- `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)
|
- `CHANGELOG.md` (EN) : header `## [Unreleased]`
|
||||||
- `CHANGELOG.fr.md` (FR)
|
- `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide)
|
||||||
- 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.
|
- 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.
|
||||||
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,3 +48,4 @@ updated: 2026-04-19
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -63,8 +63,9 @@ src-tauri/icons/ios/
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
decisions-log.md
|
decisions-log.md
|
||||||
|
|
||||||
# Autopilot scratch + daily reports
|
# Autopilot scratch + daily reports (repo root only — must not match
|
||||||
reports/
|
# src/components/reports/, which is real tracked source)
|
||||||
|
/reports/
|
||||||
|
|
||||||
# Spec scratch (committed only when promoted to docs/archive/)
|
# Spec scratch (committed only when promoted to docs/archive/)
|
||||||
spec-decisions-*.md
|
spec-decisions-*.md
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,58 @@
|
||||||
|
|
||||||
## [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
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- Rapports → Comparaison (réel vs réel) : le rapport est désormais une **analyse de résultat**. Les catégories de revenu apparaissent aux côtés des dépenses, avec deux lignes de résultat — **résultat avant transferts** (revenus − dépenses) et **résultat net** (après annulation des transferts) — pour voir d'un coup d'œil si la période dégage un surplus ou un déficit. Les catégories sont regroupées Revenus → Dépenses → Transferts, les couleurs suivent le sens (plus de revenu = vert, plus de dépense = rouge), et un montant de résultat passe au vert pour un surplus, au rouge pour un déficit (#253).
|
||||||
|
- Rapports : les rapports comparables s'ouvrent désormais par défaut sur le **mois précédent (dernier mois complet)** au lieu de décembre de l'année civile courante (#253).
|
||||||
|
- Rapports → Tendances → Par catégorie (vue tableau) : le tableau des catégories dans le temps se lit désormais comme une analyse de résultat, à l'image du rapport de comparaison. Les catégories sont regroupées en sections **Revenus → Dépenses → Transferts** avec des sous-totaux par mois et un total, et l'ancienne ligne « Total » mélangée — qui additionnait revenus, dépenses et transferts en valeurs absolues pour un chiffre sans signification — est remplacée par une ligne **Résultat net** par mois (revenus − dépenses + transferts), colorée en vert pour un surplus et en rouge pour un déficit, plus une ligne **Résultat avant transferts** affichée lorsque des transferts existent. Les cellules de catégorie restent des niveaux neutres ; seules les lignes de résultat portent une couleur de signe (#256).
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Rapports → Comparaison (réel vs réel) : le rapport comparable s'affiche désormais en **hiérarchie de catégories avec sous-totaux**, comme le rapport de budget. Les catégories parentes regroupent leurs enfants dans une ligne de sous-total — sa valeur est le net du groupe, donc un groupe de transferts équilibrés s'annule à ~0 — avec les lignes réparties en sections Dépenses / Revenus / Transferts portant des totaux par section et un total général, plus un choix sous-totaux en haut/en bas. Les montants des catégories feuilles sont inchangés, et l'annulation des transferts du correctif précédent est préservée (#247).
|
||||||
|
- Bilan : un compte détaillé (par titre) peut désormais **importer ses positions depuis un CSV** au lieu d'ajouter chaque titre un à un. Depuis un snapshot, un bouton « Importer un CSV » à côté de « Ajouter un titre » ouvre un sélecteur de fichier ; le séparateur, l'encodage et les colonnes (symbole, quantité et — au besoin — cours et coût d'acquisition) sont détectés automatiquement et ajustables avant l'import. Les lignes partageant un symbole sont fusionnées en une seule position, et un CSV sans colonne de cours importe tout de même les quantités pour récupérer ou saisir le prix ensuite (#245).
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- Bilan : la page d'accueil du bilan est désormais un hub de tuiles. Avoir des comptes mais aucun snapshot ne vous bloque plus sur une invite « créer un snapshot » — la page affiche maintenant des tuiles de navigation, et **Gérer les comptes** est accessible en tout temps, y compris depuis le tableau de bord peuplé. La page de gestion des comptes (créer/modifier/archiver les comptes et les types d'actif) n'était auparavant accessible qu'en tapant son URL (#244).
|
||||||
|
- Migration des catégories (Paramètres → Catégories → migrer vers le jeu standard) : la catégorie cible de l'étape d'aperçu est désormais modifiable sur **chaque** ligne, et non plus seulement celles « à réviser ». Chaque ligne offre un champ de recherche avec autocomplétion (insensible aux accents), pour corriger une cible détectée automatiquement aussi facilement que résoudre une ligne sans correspondance (#246).
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238).
|
||||||
|
- Épinglé la dépendance transitive de build `@babel/core` en 7.29.7 via une entrée `overrides` scopée (sous `@vitejs/plugin-react`), corrigeant le dernier advisory `npm audit` (GHSA-4x5r-pxfx-6jf8, lecture de fichier arbitraire via un commentaire `sourceMappingURL`, sévérité faible). Outillage de build uniquement — aucun changement runtime ni de comportement — et `npm audit` est désormais propre (#241).
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- Rapports → Comparaison (réel vs réel) : les catégories de type transfert (ex. « Paiement CC ») s'annulent maintenant à ~0 au lieu d'afficher seulement leurs sorties. Un transfert est un mouvement d'argent, pas une dépense — son débit et son crédit correspondant sur la même période se compensent. Les catégories de dépense et de revenu conservent exactement leurs montants précédents (#243).
|
||||||
|
|
||||||
## [0.10.1] - 2026-06-30
|
## [0.10.1] - 2026-06-30
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
|
||||||
52
CHANGELOG.md
52
CHANGELOG.md
|
|
@ -2,6 +2,58 @@
|
||||||
|
|
||||||
## [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
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reports → Compare (real vs real): the report is now an **income statement**. Revenue categories are shown alongside expenses, with two result lines — **result before transfers** (revenues − expenses) and the **net result** (after netting transfers) — so you can see at a glance whether the period ran a surplus or a deficit. Categories are grouped Income → Expenses → Transfers, colours follow direction (more income is green, more spending is red), and a result amount turns green for a surplus, red for a deficit (#253).
|
||||||
|
- Reports: the comparable reports now open on the **previous (last complete) month** by default instead of the current civil year's December (#253).
|
||||||
|
- Reports → Trends → By category (table view): the category-over-time table now reads as a result analysis, mirroring the compare report. Categories are grouped into **Income → Expenses → Transfers** sections with per-month and total subtotals, and the old mixed "Total" row — which summed income, expenses, and transfers as absolute magnitudes into a meaningless figure — is replaced by a **Net result** row per month (income − expenses + transfers), coloured green for a surplus and red for a deficit, plus a **Result before transfers** row shown when transfers exist. Category cells stay neutral levels; only the result rows carry a sign colour (#256).
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reports → Compare (real vs real): the comparable report is now shown as a **category hierarchy with subtotals**, like the budget report. Parent categories roll their children up into a subtotal row — its value is the group's net, so a group of balanced transfers subtotals to ~0 — with rows grouped into Expenses / Income / Transfers sections carrying per-section and grand totals, and a subtotals-on-top/bottom toggle. Leaf-category figures are unchanged, and the transfer netting from the previous fix is preserved (#247).
|
||||||
|
- Balance: a detailed (by-security) account can now **import its positions from a CSV** instead of adding each security one by one. From a snapshot, an "Import CSV" button next to "Add a title" opens a file picker; the delimiter, encoding, and columns (symbol, quantity, and — optionally — price and cost basis) are auto-detected and can be adjusted before importing. Rows that share a symbol are merged into one position, and a CSV without a price column still imports the quantities so the price can be fetched or typed afterwards (#245).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Balance: the balance-sheet landing page is now a tile-based hub. Having accounts but no snapshot yet no longer strands you on a "create a snapshot" prompt — the page now shows navigation tiles, and **Manage accounts** is reachable at all times, including from the populated dashboard. The account-management page (create/edit/archive accounts and asset types) was previously only reachable by typing its URL (#244).
|
||||||
|
- Categories migration (Settings → Categories → migrate to the standard set): the target category on the preview step is now editable on **every** row, not only the "needs review" ones. Each row carries a type-ahead picker (accent-insensitive search) so you can override an auto-detected target as easily as resolving an unmatched one (#246).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238).
|
||||||
|
- Pinned the build-time transitive dependency `@babel/core` to 7.29.7 through a scoped `overrides` entry (under `@vitejs/plugin-react`), clearing the last `npm audit` advisory (GHSA-4x5r-pxfx-6jf8, arbitrary file read via a `sourceMappingURL` comment, low severity). Build tooling only — no runtime or behaviour change — and `npm audit` is now clean (#241).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reports → Compare (real vs real): transfer-type categories (e.g. "Paiement CC") now net to ~0 instead of showing only their outflows. A transfer is a money move, not spending — its debit and matching credit over the same period cancel out. Expense and income categories keep exactly their previous amounts (#243).
|
||||||
|
|
||||||
## [0.10.1] - 2026-06-30
|
## [0.10.1] - 2026-06-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
29
STATE.md
29
STATE.md
|
|
@ -1,25 +1,32 @@
|
||||||
# STATE — Simpl'Résultat
|
# STATE — Simpl'Résultat
|
||||||
|
|
||||||
> Derniere MAJ : 2026-06-29 (Etape 2 bilan complete — #228 mergé, milestone fermée, prêt pour release 0.10.0)
|
> 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)
|
||||||
|
|
||||||
## Position actuelle
|
## Position actuelle
|
||||||
|
|
||||||
v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-seed-categories-ipc`, `spec-price-fetching`, `overnight-2026-04-26-bilan` et `overnight-2026-04-27-prices` complétées et fermées. Maximus-api 0.3.0 LIVE en prod (`api.lacompagniemaximus.com`) — pubkey Ed25519 alignée, smoke test Phase B (#161) validé. Backlog actif concentré sur `spec-monetisation` (7/12) : activation en ligne (#53), pipeline Stripe desktop (#50, #52) et maximus-api Stripe webhooks (#135, #136).
|
v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-seed-categories-ipc`, `spec-price-fetching`, `overnight-2026-04-26-bilan` et `overnight-2026-04-27-prices` complétées et fermées. Maximus-api 0.3.0 LIVE en prod (`api.lacompagniemaximus.com`) — pubkey Ed25519 alignée, smoke test Phase B (#161) validé. Backlog actif concentré sur `spec-monetisation` (7/12) : activation en ligne (#53), pipeline Stripe desktop (#50, #52) et maximus-api Stripe webhooks (#135, #136).
|
||||||
|
|
||||||
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. Prochaine étape : **tag release 0.10.0** (Étapes 1+2 sous [Unreleased] EN+FR, jamais taggées — v0.9.1 s'arrêtait à v13). 20 tables / 24 index, 16 migrations (v1→v16).
|
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. **v0.10.0 shippée** (2026-06-29 : Étapes 1+2 du bilan, migrations v12→v16) **puis hotfix v0.10.1** (2026-06-30, PR #230, déployé) — corrige un « database is locked » introduit en 0.10.0 (apparaissait après l'abandon d'un snapshot en cours) : tout l'accès DB est désormais sérialisé via `withTransaction` dans `db.ts` (tauri-plugin-sql = pool sqlx multi-connexions sans primitive de transaction JS → `BEGIN`/`COMMIT` en `db.execute` séparés pouvaient strander une transaction d'écriture = verrou zombie), appliqué aux 5 sites transactionnels ; + console de log live-update + API `logInfo/logWarn/logError`. 20 tables / 24 index, 16 migrations (v1→v16).
|
||||||
|
|
||||||
|
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
|
||||||
|
|
||||||
|
**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-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-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)
|
|
||||||
- 2026-05-03 : Bilan post-merge cleanup (S1-S5+S7) — `getStarterCollisions` filtre `archived_at IS NULL`, in-txn re-check sur `proposeStarterAccounts`, pre-seed `balance_starter_proposed`, guard empty-state `/balance`, doctest fence `text` Modified Dietz (ref #187)
|
|
||||||
- 2026-05-03 : WebKitGTK date picker workaround etendu aux 7 inputs date restants dans 4 composants (TransactionFilterBar, AdjustmentForm, LinkTransfersModal, PeriodSelector) (ref #188)
|
|
||||||
- 2026-05-03 : postcss 8.5.6 -> 8.5.13, fix GHSA-qx2v-qp2m-jg93 (transitif via vite, build-time only) (ref #180)
|
|
||||||
- 2026-05-02 : Settings eclate en 3 sous-pages `/settings/{users,data,systems}` + redirections legacy `/docs` et `/changelog` (ref #190)
|
|
||||||
|
|
||||||
## Blockers actifs
|
## Blockers actifs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
|
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
|
||||||
| `adjustmentService.ts` | Gestion des ajustements |
|
| `adjustmentService.ts` | Gestion des ajustements |
|
||||||
| `budgetService.ts` | Gestion budgétaire |
|
| `budgetService.ts` | Gestion budgétaire |
|
||||||
| `dashboardService.ts` | Agrégation données tableau de bord |
|
| `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) |
|
||||||
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
||||||
| `dataExportService.ts` | Export de données (chiffré) |
|
| `dataExportService.ts` | Export de données (chiffré) |
|
||||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
|
|
@ -205,8 +205,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useImportHistory` | Historique des imports |
|
| `useImportHistory` | Historique des imports |
|
||||||
| `useAdjustments` | Ajustements |
|
| `useAdjustments` | Ajustements |
|
||||||
| `useBudget` | Budget |
|
| `useBudget` | Budget |
|
||||||
| `useDashboard` | Métriques du tableau de bord |
|
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
|
||||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
|
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
|
||||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||||
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
||||||
|
|
@ -350,7 +350,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
||||||
|
|
||||||
| Route | Page | Description |
|
| Route | Page | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
|
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
|
||||||
| `/import` | `ImportPage` | Assistant d'import CSV |
|
| `/import` | `ImportPage` | Assistant d'import CSV |
|
||||||
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
||||||
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
||||||
|
|
|
||||||
426
package-lock.json
generated
426
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.10.1",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.10.1",
|
"version": "0.13.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",
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.18.1",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -35,17 +35,18 @@
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.4.1",
|
"vite": "^6.4.3",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
|
|
@ -54,29 +55,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
|
|
@ -93,13 +96,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
|
|
@ -109,13 +113,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.28.6",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
|
|
@ -125,36 +130,39 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.28.6",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.6"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -173,52 +181,57 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
|
|
@ -266,31 +279,33 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -298,13 +313,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -1964,31 +1980,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "4.0.18",
|
"@vitest/spy": "4.1.9",
|
||||||
"@vitest/utils": "4.0.18",
|
"@vitest/utils": "4.1.9",
|
||||||
"chai": "^6.2.1",
|
"chai": "^6.2.2",
|
||||||
"tinyrainbow": "^3.0.3"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.0.18",
|
"@vitest/spy": "4.1.9",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
|
|
@ -1997,7 +2013,7 @@
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"msw": "^2.4.9",
|
"msw": "^2.4.9",
|
||||||
"vite": "^6.0.0 || ^7.0.0-0"
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"msw": {
|
"msw": {
|
||||||
|
|
@ -2009,26 +2025,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tinyrainbow": "^3.0.3"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.0.18",
|
"@vitest/utils": "4.1.9",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|
@ -2036,13 +2052,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.0.18",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
|
|
@ -2051,9 +2068,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|
@ -2061,14 +2078,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.0.18",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"tinyrainbow": "^3.0.3"
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
|
|
@ -2085,18 +2103,22 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.10.41",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
|
||||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -2112,12 +2134,13 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001799",
|
||||||
"electron-to-chromium": "^1.5.263",
|
"electron-to-chromium": "^1.5.376",
|
||||||
"node-releases": "^2.0.27",
|
"node-releases": "^2.0.48",
|
||||||
"update-browserslist-db": "^1.2.0"
|
"update-browserslist-db": "^1.2.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"browserslist": "cli.js"
|
"browserslist": "cli.js"
|
||||||
|
|
@ -2127,9 +2150,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001769",
|
"version": "1.0.30001800",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||||
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
|
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -2144,7 +2167,8 @@
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
|
|
@ -2174,6 +2198,7 @@
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
@ -2303,6 +2328,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
|
|
@ -2330,10 +2356,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.286",
|
"version": "1.5.384",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
|
||||||
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
|
"integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.19.0",
|
"version": "5.19.0",
|
||||||
|
|
@ -2349,9 +2376,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "1.7.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -2406,6 +2433,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
|
@ -2549,13 +2577,15 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jsesc": "bin/jsesc"
|
"jsesc": "bin/jsesc"
|
||||||
},
|
},
|
||||||
|
|
@ -2829,6 +2859,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2854,7 +2885,8 @@
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
|
|
@ -2875,10 +2907,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.50",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
|
|
@ -3034,9 +3070,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.13.0",
|
"version": "7.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
"set-cookie-parser": "^2.6.0"
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
|
@ -3055,11 +3092,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.13.0",
|
"version": "7.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.13.0"
|
"react-router": "7.18.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|
@ -3168,6 +3206,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
|
|
@ -3175,7 +3214,8 @@
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
|
@ -3201,9 +3241,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -3265,9 +3305,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyrainbow": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "3.0.3",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -3317,6 +3357,7 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"escalade": "^3.2.0",
|
"escalade": "^3.2.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
|
|
@ -3358,9 +3399,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -3433,31 +3474,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.0.18",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.18",
|
"@vitest/expect": "4.1.9",
|
||||||
"@vitest/mocker": "4.0.18",
|
"@vitest/mocker": "4.1.9",
|
||||||
"@vitest/pretty-format": "4.0.18",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"@vitest/runner": "4.0.18",
|
"@vitest/runner": "4.1.9",
|
||||||
"@vitest/snapshot": "4.0.18",
|
"@vitest/snapshot": "4.1.9",
|
||||||
"@vitest/spy": "4.0.18",
|
"@vitest/spy": "4.1.9",
|
||||||
"@vitest/utils": "4.0.18",
|
"@vitest/utils": "4.1.9",
|
||||||
"es-module-lexer": "^1.7.0",
|
"es-module-lexer": "^2.0.0",
|
||||||
"expect-type": "^1.2.2",
|
"expect-type": "^1.3.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"obug": "^2.1.1",
|
"obug": "^2.1.1",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"std-env": "^3.10.0",
|
"std-env": "^4.0.0-rc.1",
|
||||||
"tinybench": "^2.9.0",
|
"tinybench": "^2.9.0",
|
||||||
"tinyexec": "^1.0.2",
|
"tinyexec": "^1.0.2",
|
||||||
"tinyglobby": "^0.2.15",
|
"tinyglobby": "^0.2.15",
|
||||||
"tinyrainbow": "^3.0.3",
|
"tinyrainbow": "^3.1.0",
|
||||||
"vite": "^6.0.0 || ^7.0.0",
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
"why-is-node-running": "^2.3.0"
|
"why-is-node-running": "^2.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -3473,12 +3514,15 @@
|
||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser-playwright": "4.0.18",
|
"@vitest/browser-playwright": "4.1.9",
|
||||||
"@vitest/browser-preview": "4.0.18",
|
"@vitest/browser-preview": "4.1.9",
|
||||||
"@vitest/browser-webdriverio": "4.0.18",
|
"@vitest/browser-webdriverio": "4.1.9",
|
||||||
"@vitest/ui": "4.0.18",
|
"@vitest/coverage-istanbul": "4.1.9",
|
||||||
|
"@vitest/coverage-v8": "4.1.9",
|
||||||
|
"@vitest/ui": "4.1.9",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*"
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edge-runtime/vm": {
|
"@edge-runtime/vm": {
|
||||||
|
|
@ -3499,6 +3543,12 @@
|
||||||
"@vitest/browser-webdriverio": {
|
"@vitest/browser-webdriverio": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"@vitest/ui": {
|
"@vitest/ui": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|
@ -3507,6 +3557,9 @@
|
||||||
},
|
},
|
||||||
"jsdom": {
|
"jsdom": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3539,7 +3592,8 @@
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
package.json
13
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.1",
|
"version": "0.13.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.18.1",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -39,7 +39,12 @@
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.4.1",
|
"vite": "^6.4.3",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.9"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@vitejs/plugin-react": {
|
||||||
|
"@babel/core": "^7.29.7"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -4509,7 +4509,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.10.1"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.10.1"
|
version = "0.13.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"]
|
||||||
|
|
|
||||||
|
|
@ -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.10.1",
|
"version": "0.13.0",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
// BalanceOnboardingCard — unit tests (issue #178)
|
|
||||||
//
|
|
||||||
// NOTE: This project does not have @testing-library/react or jsdom configured
|
|
||||||
// (logged as MEDIUM in autopilot decisions-log). Tests cover the pure
|
|
||||||
// `deriveOnboardingSteps` helper that drives the visual state of each step.
|
|
||||||
// All React rendering is bypassed.
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { deriveOnboardingSteps } from "./BalanceOnboardingCard";
|
|
||||||
|
|
||||||
describe("BalanceOnboardingCard — deriveOnboardingSteps", () => {
|
|
||||||
it("0 accounts, 0 snapshots → step1 active, step2 disabled", () => {
|
|
||||||
const r = deriveOnboardingSteps(0, 0);
|
|
||||||
expect(r.step1).toBe("active");
|
|
||||||
expect(r.step2).toBe("disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it(">=1 account, 0 snapshots → step1 done, step2 active", () => {
|
|
||||||
const r = deriveOnboardingSteps(1, 0);
|
|
||||||
expect(r.step1).toBe("done");
|
|
||||||
expect(r.step2).toBe("active");
|
|
||||||
|
|
||||||
const r2 = deriveOnboardingSteps(5, 0);
|
|
||||||
expect(r2.step1).toBe("done");
|
|
||||||
expect(r2.step2).toBe("active");
|
|
||||||
});
|
|
||||||
|
|
||||||
it(">=1 account, >=1 snapshot → both done (defensive — card normally hidden)", () => {
|
|
||||||
const r = deriveOnboardingSteps(2, 3);
|
|
||||||
expect(r.step1).toBe("done");
|
|
||||||
expect(r.step2).toBe("done");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("guard: 0 accounts but >=1 snapshot (anomaly) → step1 active, step2 done", () => {
|
|
||||||
// This combination should not happen in practice (a snapshot requires at
|
|
||||||
// least one account), but the helper handles it conservatively.
|
|
||||||
const r = deriveOnboardingSteps(0, 1);
|
|
||||||
expect(r.step1).toBe("active");
|
|
||||||
expect(r.step2).toBe("done");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
// BalanceOnboardingCard — empty-state onboarding for /balance.
|
|
||||||
//
|
|
||||||
// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts
|
|
||||||
// or no snapshots yet. Two vertical steps:
|
|
||||||
// 1. Create an account → /balance/accounts
|
|
||||||
// 2. Enter a snapshot → /balance/snapshot
|
|
||||||
//
|
|
||||||
// Each step has 3 states:
|
|
||||||
// - "active": primary CTA, currently the next thing to do
|
|
||||||
// - "done": marked with a checkmark, no CTA
|
|
||||||
// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled
|
|
||||||
//
|
|
||||||
// The whole card is replaced by BalanceOverviewCard once at least one
|
|
||||||
// snapshot exists, so step 2 in practice is rendered as "active" or
|
|
||||||
// "disabled"; the "done" branch is supported for completeness/tests.
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
interface BalanceOnboardingCardProps {
|
|
||||||
/** Number of active (non-archived) accounts. */
|
|
||||||
accountsCount: number;
|
|
||||||
/** Number of snapshots saved (any date). */
|
|
||||||
snapshotsCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StepState = "active" | "done" | "disabled";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure helper exposed for unit tests — derives the state of each onboarding
|
|
||||||
* step from the (accountsCount, snapshotsCount) pair.
|
|
||||||
*
|
|
||||||
* - Step 1 is "done" once at least one account exists, "active" otherwise.
|
|
||||||
* - Step 2 is "done" once any snapshot exists, "active" once at least one
|
|
||||||
* account exists, "disabled" otherwise. In practice the parent guard on
|
|
||||||
* /balance only renders this card when snapshotsCount === 0, so the
|
|
||||||
* "done" branch for step 2 is mostly defensive.
|
|
||||||
*/
|
|
||||||
export function deriveOnboardingSteps(
|
|
||||||
accountsCount: number,
|
|
||||||
snapshotsCount: number
|
|
||||||
): { step1: StepState; step2: StepState } {
|
|
||||||
const step1: StepState = accountsCount >= 1 ? "done" : "active";
|
|
||||||
const step2: StepState =
|
|
||||||
snapshotsCount >= 1
|
|
||||||
? "done"
|
|
||||||
: accountsCount >= 1
|
|
||||||
? "active"
|
|
||||||
: "disabled";
|
|
||||||
return { step1, step2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BalanceOnboardingCard({
|
|
||||||
accountsCount,
|
|
||||||
snapshotsCount,
|
|
||||||
}: BalanceOnboardingCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { step1: step1State, step2: step2State } = deriveOnboardingSteps(
|
|
||||||
accountsCount,
|
|
||||||
snapshotsCount
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-1">
|
|
||||||
{t("balance.onboarding.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-5">
|
|
||||||
{t("balance.onboarding.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ol className="space-y-3">
|
|
||||||
<Step
|
|
||||||
number={1}
|
|
||||||
state={step1State}
|
|
||||||
icon={<Wallet size={18} />}
|
|
||||||
title={t("balance.onboarding.step1.title")}
|
|
||||||
description={t("balance.onboarding.step1.description")}
|
|
||||||
ctaLabel={t("balance.onboarding.step1.cta")}
|
|
||||||
ctaHref="/balance/accounts"
|
|
||||||
/>
|
|
||||||
<Step
|
|
||||||
number={2}
|
|
||||||
state={step2State}
|
|
||||||
icon={<FileText size={18} />}
|
|
||||||
title={t("balance.onboarding.step2.title")}
|
|
||||||
description={t("balance.onboarding.step2.description")}
|
|
||||||
ctaLabel={t("balance.onboarding.step2.cta")}
|
|
||||||
ctaHref="/balance/snapshot"
|
|
||||||
disabledHint={t("balance.onboarding.step2.disabledHint")}
|
|
||||||
/>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Internal — single step row
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface StepProps {
|
|
||||||
number: number;
|
|
||||||
state: StepState;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
ctaLabel: string;
|
|
||||||
ctaHref: string;
|
|
||||||
disabledHint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step({
|
|
||||||
number,
|
|
||||||
state,
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
ctaLabel,
|
|
||||||
ctaHref,
|
|
||||||
disabledHint,
|
|
||||||
}: StepProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isDone = state === "done";
|
|
||||||
const isActive = state === "active";
|
|
||||||
const isDisabled = state === "disabled";
|
|
||||||
|
|
||||||
// Number bubble: green check when done, primary bg when active, muted when disabled.
|
|
||||||
const bubbleClass = isDone
|
|
||||||
? "bg-[var(--positive)] text-white"
|
|
||||||
: isActive
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--muted)] text-[var(--muted-foreground)]";
|
|
||||||
|
|
||||||
const titleClass = isDisabled
|
|
||||||
? "text-[var(--muted-foreground)]"
|
|
||||||
: "text-[var(--foreground)]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-testid={`balance-onboarding-step-${number}`}
|
|
||||||
data-state={state}
|
|
||||||
className={`flex items-start gap-4 p-4 rounded-lg border ${
|
|
||||||
isDisabled
|
|
||||||
? "border-[var(--border)] opacity-60"
|
|
||||||
: "border-[var(--border)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{isDone ? <Check size={16} /> : number}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
|
||||||
{isDisabled && disabledHint && (
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
|
|
||||||
{disabledHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 self-center">
|
|
||||||
{isDone ? (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
|
|
||||||
data-testid={`balance-onboarding-step-${number}-done-badge`}
|
|
||||||
>
|
|
||||||
<Check size={14} />
|
|
||||||
{t("balance.onboarding.doneBadge")}
|
|
||||||
</span>
|
|
||||||
) : isActive ? (
|
|
||||||
<Link
|
|
||||||
to={ctaHref}
|
|
||||||
data-testid={`balance-onboarding-step-${number}-cta`}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
||||||
>
|
|
||||||
{ctaLabel}
|
|
||||||
<ArrowRight size={14} />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
data-testid={`balance-onboarding-step-${number}-cta`}
|
|
||||||
aria-disabled="true"
|
|
||||||
title={disabledHint}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{ctaLabel}
|
|
||||||
<ArrowRight size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
351
src/components/balance/HoldingsCsvImportModal.tsx
Normal file
351
src/components/balance/HoldingsCsvImportModal.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
// HoldingsCsvImportModal — bulk-import a detailed account's positions from a
|
||||||
|
// CSV instead of typing them one by one (Issue #245).
|
||||||
|
//
|
||||||
|
// Flow (all frontend — the save path is unchanged):
|
||||||
|
// 1. pick a CSV file (native dialog, reusing the existing `pick_import_file`
|
||||||
|
// Rust command with a CSV filter);
|
||||||
|
// 2. detect its encoding (`detect_encoding`, utf-8 fallback) + read it
|
||||||
|
// (`read_file_content`);
|
||||||
|
// 3. `analyzeHoldingsCsv` preprocesses, detects the delimiter/header and the
|
||||||
|
// symbol / quantity / unit_price / book_cost columns (flexible price
|
||||||
|
// detection: no price column ⇒ mapping stays null);
|
||||||
|
// 4. the user reviews/adjusts the mapping in a small editor + preview;
|
||||||
|
// 5. on confirm we hand the DATA rows + mapping up to the editor hook, which
|
||||||
|
// builds `HoldingDraft`s (FR numbers, duplicate-symbol merge) and merges
|
||||||
|
// them into the account's basket. The user then fetches/types any missing
|
||||||
|
// price and saves through the normal atomic path.
|
||||||
|
//
|
||||||
|
// The component owns the file/parse lifecycle; the actual draft building and
|
||||||
|
// basket merge live in `useSnapshotEditor` so they stay pure + unit-tested.
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Upload, X, AlertTriangle, FileSpreadsheet } from "lucide-react";
|
||||||
|
import {
|
||||||
|
analyzeHoldingsCsv,
|
||||||
|
type HoldingColumnMapping,
|
||||||
|
type HoldingCsvAnalysis,
|
||||||
|
} from "../../utils/csvAutoDetect";
|
||||||
|
import { normalizeSecuritySymbol } from "../../services/balance.service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountName: string;
|
||||||
|
/** Called with the parsed DATA rows + the confirmed column mapping. */
|
||||||
|
onImport: (rows: string[][], mapping: HoldingColumnMapping) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "select" | "loading" | "map" | "error";
|
||||||
|
|
||||||
|
const PREVIEW_ROWS = 5;
|
||||||
|
|
||||||
|
export default function HoldingsCsvImportModal({
|
||||||
|
accountName,
|
||||||
|
onImport,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [step, setStep] = useState<Step>("select");
|
||||||
|
const [analysis, setAnalysis] = useState<HoldingCsvAnalysis | null>(null);
|
||||||
|
const [mapping, setMapping] = useState<HoldingColumnMapping | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
|
|
||||||
|
const pickAndAnalyze = useCallback(async () => {
|
||||||
|
setStep("loading");
|
||||||
|
setErrorMsg("");
|
||||||
|
try {
|
||||||
|
const filePath = await invoke<string | null>("pick_import_file", {
|
||||||
|
filters: [["CSV", ["csv", "txt"]]],
|
||||||
|
});
|
||||||
|
if (!filePath) {
|
||||||
|
// User cancelled the native dialog — return to the select step.
|
||||||
|
setStep("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoding = "utf-8";
|
||||||
|
try {
|
||||||
|
encoding = await invoke<string>("detect_encoding", {
|
||||||
|
filePath,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// fall back to utf-8
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await invoke<string>("read_file_content", {
|
||||||
|
filePath,
|
||||||
|
encoding,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = analyzeHoldingsCsv(content);
|
||||||
|
if (!result) {
|
||||||
|
setErrorMsg(t("balance.snapshot.detailed.importCsv.detectError"));
|
||||||
|
setStep("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAnalysis(result);
|
||||||
|
setMapping(result.mapping);
|
||||||
|
setStep("map");
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg(
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: t("balance.snapshot.detailed.importCsv.readError")
|
||||||
|
);
|
||||||
|
setStep("error");
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const columnOptions = useMemo(() => {
|
||||||
|
if (!analysis) return null;
|
||||||
|
return analysis.headers.map((h, i) => (
|
||||||
|
<option key={i} value={i}>
|
||||||
|
{i}: {h}
|
||||||
|
</option>
|
||||||
|
));
|
||||||
|
}, [analysis]);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!analysis || !mapping) return [];
|
||||||
|
return analysis.rows.slice(0, PREVIEW_ROWS).map((row) => {
|
||||||
|
const symbol = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
|
||||||
|
const qty = (row[mapping.quantity] ?? "").trim();
|
||||||
|
const price =
|
||||||
|
mapping.unit_price !== null
|
||||||
|
? (row[mapping.unit_price] ?? "").trim()
|
||||||
|
: "";
|
||||||
|
const book =
|
||||||
|
mapping.book_cost !== null ? (row[mapping.book_cost] ?? "").trim() : "";
|
||||||
|
return { symbol, qty, price, book };
|
||||||
|
});
|
||||||
|
}, [analysis, mapping]);
|
||||||
|
|
||||||
|
// Count the distinct, non-blank symbols that would be imported (after merge).
|
||||||
|
const importableCount = useMemo(() => {
|
||||||
|
if (!analysis || !mapping) return 0;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of analysis.rows) {
|
||||||
|
const s = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
|
||||||
|
if (s) seen.add(s);
|
||||||
|
}
|
||||||
|
return seen.size;
|
||||||
|
}, [analysis, mapping]);
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
if (!analysis || !mapping) return;
|
||||||
|
onImport(analysis.rows, mapping);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]";
|
||||||
|
|
||||||
|
const setMappingField = (
|
||||||
|
field: keyof HoldingColumnMapping,
|
||||||
|
raw: string
|
||||||
|
) => {
|
||||||
|
if (!mapping) return;
|
||||||
|
const parsed = parseInt(raw, 10);
|
||||||
|
const next: number | null =
|
||||||
|
Number.isNaN(parsed) || parsed < 0 ? null : parsed;
|
||||||
|
setMapping({ ...mapping, [field]: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<FileSpreadsheet size={20} className="text-[var(--primary)]" />
|
||||||
|
<h2 className="text-lg font-semibold truncate">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.title")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
|
||||||
|
aria-label={t("common.cancel")}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.intro", {
|
||||||
|
account: accountName,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(step === "select" || step === "loading") && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pickAndAnalyze}
|
||||||
|
disabled={step === "loading"}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
{step === "loading"
|
||||||
|
? t("balance.snapshot.detailed.importCsv.loading")
|
||||||
|
: t("balance.snapshot.detailed.importCsv.selectFile")}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] text-center">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "error" && (
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<span>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep("select")}
|
||||||
|
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{t("balance.snapshot.detailed.importCsv.retry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "map" && analysis && mapping && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.symbolColumn")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={mapping.symbol}
|
||||||
|
onChange={(e) => setMappingField("symbol", e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{columnOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.quantityColumn")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={mapping.quantity}
|
||||||
|
onChange={(e) => setMappingField("quantity", e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{columnOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.priceColumn")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={mapping.unit_price ?? -1}
|
||||||
|
onChange={(e) => setMappingField("unit_price", e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value={-1}>
|
||||||
|
{t("balance.snapshot.detailed.importCsv.noColumn")}
|
||||||
|
</option>
|
||||||
|
{columnOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.bookCostColumn")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={mapping.book_cost ?? -1}
|
||||||
|
onChange={(e) => setMappingField("book_cost", e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value={-1}>
|
||||||
|
{t("balance.snapshot.detailed.importCsv.noColumn")}
|
||||||
|
</option>
|
||||||
|
{columnOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mapping.unit_price === null && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] italic">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.noPriceNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview of the first rows as they will be imported. */}
|
||||||
|
<div className="rounded-lg border border-[var(--border)] overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[var(--muted)] text-left">
|
||||||
|
<th className="px-2 py-1.5 font-medium">
|
||||||
|
{t("balance.snapshot.detailed.col.title")}
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-right">
|
||||||
|
{t("balance.snapshot.detailed.col.quantity")}
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-right">
|
||||||
|
{t("balance.snapshot.detailed.col.unitPrice")}
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-right">
|
||||||
|
{t("balance.snapshot.detailed.col.bookCost")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.map((r, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className="border-t border-[var(--border)] tabular-nums"
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5 font-medium">
|
||||||
|
{r.symbol || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">{r.qty || "—"}</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{r.price || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">{r.book || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.snapshot.detailed.importCsv.summary", {
|
||||||
|
count: importableCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importableCount === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
{t("balance.snapshot.detailed.importCsv.import")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
BalanceSecurity,
|
BalanceSecurity,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
||||||
|
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
|
||||||
import type { SecurityPick } from "./SecurityPicker";
|
import type { SecurityPick } from "./SecurityPicker";
|
||||||
import SnapshotLineRow from "./SnapshotLineRow";
|
import SnapshotLineRow from "./SnapshotLineRow";
|
||||||
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
||||||
|
|
@ -42,6 +43,12 @@ interface Props {
|
||||||
rowId: string,
|
rowId: string,
|
||||||
pick: SecurityPick
|
pick: SecurityPick
|
||||||
) => void;
|
) => void;
|
||||||
|
/** Import a batch of holdings from a CSV into a detailed account (#245). */
|
||||||
|
onImportHoldings: (
|
||||||
|
accountId: number,
|
||||||
|
rows: string[][],
|
||||||
|
mapping: HoldingColumnMapping
|
||||||
|
) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
|
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
|
||||||
snapshotDate?: string;
|
snapshotDate?: string;
|
||||||
|
|
@ -58,6 +65,7 @@ export default function SnapshotEditor({
|
||||||
onRemoveHolding,
|
onRemoveHolding,
|
||||||
onHoldingFieldChange,
|
onHoldingFieldChange,
|
||||||
onHoldingSecurityPick,
|
onHoldingSecurityPick,
|
||||||
|
onImportHoldings,
|
||||||
disabled,
|
disabled,
|
||||||
snapshotDate,
|
snapshotDate,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
@ -124,6 +132,9 @@ export default function SnapshotEditor({
|
||||||
onHoldingSecurityPick={(rowId, pick) =>
|
onHoldingSecurityPick={(rowId, pick) =>
|
||||||
onHoldingSecurityPick(acc.id, rowId, pick)
|
onHoldingSecurityPick(acc.id, rowId, pick)
|
||||||
}
|
}
|
||||||
|
onImportHoldings={(rows, mapping) =>
|
||||||
|
onImportHoldings(acc.id, rows, mapping)
|
||||||
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
snapshotDate={snapshotDate}
|
snapshotDate={snapshotDate}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,18 @@
|
||||||
// strings on every change. Numeric validation happens at save time in
|
// strings on every change. Numeric validation happens at save time in
|
||||||
// `useSnapshotEditor.save`.
|
// `useSnapshotEditor.save`.
|
||||||
|
|
||||||
import { ChangeEvent, useMemo } from "react";
|
import { ChangeEvent, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2, Upload } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
BalanceAccountWithCategory,
|
BalanceAccountWithCategory,
|
||||||
BalanceSecurity,
|
BalanceSecurity,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
||||||
|
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
|
||||||
import PriceFetchControl from "./PriceFetchControl";
|
import PriceFetchControl from "./PriceFetchControl";
|
||||||
import SecurityPicker, { type SecurityPick } from "./SecurityPicker";
|
import SecurityPicker, { type SecurityPick } from "./SecurityPicker";
|
||||||
|
import HoldingsCsvImportModal from "./HoldingsCsvImportModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
account: BalanceAccountWithCategory;
|
account: BalanceAccountWithCategory;
|
||||||
|
|
@ -57,6 +59,8 @@ interface Props {
|
||||||
) => void;
|
) => void;
|
||||||
/** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */
|
/** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */
|
||||||
onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void;
|
onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void;
|
||||||
|
/** Import a batch of holdings from a CSV into this detailed account (#245). */
|
||||||
|
onImportHoldings?: (rows: string[][], mapping: HoldingColumnMapping) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -84,9 +88,11 @@ export default function SnapshotLineRow({
|
||||||
onRemoveHolding,
|
onRemoveHolding,
|
||||||
onHoldingFieldChange,
|
onHoldingFieldChange,
|
||||||
onHoldingSecurityPick,
|
onHoldingSecurityPick,
|
||||||
|
onImportHoldings,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDetailed = account.kind === "detailed";
|
const isDetailed = account.kind === "detailed";
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
|
||||||
// Account total across the basket (live as the user types).
|
// Account total across the basket (live as the user types).
|
||||||
const detailedTotal = useMemo(() => {
|
const detailedTotal = useMemo(() => {
|
||||||
|
|
@ -152,15 +158,36 @@ export default function SnapshotLineRow({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div className="mt-2 flex items-center gap-4">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onAddHolding?.()}
|
type="button"
|
||||||
disabled={disabled}
|
onClick={() => onAddHolding?.()}
|
||||||
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
disabled={disabled}
|
||||||
>
|
className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
||||||
<Plus size={13} />
|
>
|
||||||
{t("balance.snapshot.detailed.addTitle")}
|
<Plus size={13} />
|
||||||
</button>
|
{t("balance.snapshot.detailed.addTitle")}
|
||||||
|
</button>
|
||||||
|
{onImportHoldings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImportOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Upload size={13} />
|
||||||
|
{t("balance.snapshot.detailed.importCsv.button")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importOpen && onImportHoldings && (
|
||||||
|
<HoldingsCsvImportModal
|
||||||
|
accountName={account.name}
|
||||||
|
onImport={onImportHoldings}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
src/components/balance/balanceLanding.test.ts
Normal file
30
src/components/balance/balanceLanding.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// balanceLanding — unit tests (issue #244)
|
||||||
|
//
|
||||||
|
// Covers the pure `deriveLandingState` helper that replaces the old boolean
|
||||||
|
// empty-state guard. No React rendering (this project has no jsdom configured);
|
||||||
|
// the decoupled-guard logic is exercised directly as a pure helper.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { deriveLandingState } from "./balanceLanding";
|
||||||
|
|
||||||
|
describe("balanceLanding — deriveLandingState", () => {
|
||||||
|
it("0 accounts → empty (regardless of snapshot flag)", () => {
|
||||||
|
expect(deriveLandingState(0, false)).toBe("empty");
|
||||||
|
// Anomalous (a snapshot implies an account) but handled conservatively.
|
||||||
|
expect(deriveLandingState(0, true)).toBe("empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account but no snapshot → accounts-no-snapshot (no longer 'empty')", () => {
|
||||||
|
expect(deriveLandingState(1, false)).toBe("accounts-no-snapshot");
|
||||||
|
expect(deriveLandingState(5, false)).toBe("accounts-no-snapshot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account with a snapshot → populated", () => {
|
||||||
|
expect(deriveLandingState(1, true)).toBe("populated");
|
||||||
|
expect(deriveLandingState(12, true)).toBe("populated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against negative counts → empty", () => {
|
||||||
|
expect(deriveLandingState(-1, true)).toBe("empty");
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/components/balance/balanceLanding.ts
Normal file
37
src/components/balance/balanceLanding.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// balanceLanding.ts — pure landing-state helper for the /balance page.
|
||||||
|
//
|
||||||
|
// Issue #244. Decouples the old boolean empty-state guard
|
||||||
|
// (`accountsCount === 0 || !hasAnySnapshot`) into three explicit states so the
|
||||||
|
// UI can stop conflating "no account yet" with "accounts but no snapshot yet":
|
||||||
|
//
|
||||||
|
// - "empty": no active account at all → offer account creation
|
||||||
|
// (StarterAccountsModal + a "Manage accounts" tile).
|
||||||
|
// - "accounts-no-snapshot": at least one account but no snapshot → offer BOTH
|
||||||
|
// "Manage accounts" and "New snapshot" tiles, without
|
||||||
|
// forcing the user toward the snapshot flow.
|
||||||
|
// - "populated": at least one account with at least one snapshot →
|
||||||
|
// render the full dashboard (overview + chart +
|
||||||
|
// accounts table) plus the navigation tiles.
|
||||||
|
//
|
||||||
|
// Extracted as a pure, DOM-free helper because this project has no
|
||||||
|
// @testing-library/react or jsdom configured — UI branching is unit-tested via
|
||||||
|
// pure helpers (same convention as `deriveOnboardingSteps` / `computeBalanceDateRange`).
|
||||||
|
|
||||||
|
export type BalanceLandingState = "empty" | "accounts-no-snapshot" | "populated";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the landing state from the two independent signals the balance page
|
||||||
|
* already computes: the number of active accounts and whether ANY snapshot
|
||||||
|
* exists (probed across accounts, independent of the active period filter).
|
||||||
|
*
|
||||||
|
* @param accountsCount number of active (non-archived) accounts
|
||||||
|
* @param hasAnySnapshot true when at least one account carries a snapshot date
|
||||||
|
*/
|
||||||
|
export function deriveLandingState(
|
||||||
|
accountsCount: number,
|
||||||
|
hasAnySnapshot: boolean
|
||||||
|
): BalanceLandingState {
|
||||||
|
if (accountsCount <= 0) return "empty";
|
||||||
|
if (!hasAnySnapshot) return "accounts-no-snapshot";
|
||||||
|
return "populated";
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", {
|
const fmt = new Intl.NumberFormat("en-CA", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
grouped[key].push(row);
|
grouped[key].push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeOrder = ["expense", "income", "transfer"] as const;
|
// Income-statement reading order: revenue first, then expenses, then
|
||||||
|
// transfers (Issue #278) — matches the compare/trend reports' ordering.
|
||||||
|
const typeOrder = ["income", "expense", "transfer"] as const;
|
||||||
const typeLabelKeys: Record<string, string> = {
|
const typeLabelKeys: Record<string, string> = {
|
||||||
expense: "budget.expenses",
|
expense: "budget.expenses",
|
||||||
income: "budget.income",
|
income: "budget.income",
|
||||||
|
|
@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
transfer: "budget.totalTransfers",
|
transfer: "budget.totalTransfers",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
|
||||||
const monthTotals: number[] = Array(12).fill(0);
|
// computed on the budgeted + previous-year-actual leaves. Mathematically
|
||||||
let annualTotal = 0;
|
// equivalent to the old plain grand-total (income − expense + transfer over
|
||||||
let prevYearTotal = 0;
|
// every leaf), now surfaced as the two labeled result rows below instead of
|
||||||
for (const row of rows) {
|
// a single unlabeled "Total".
|
||||||
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
const results = computeBudgetResults(rows);
|
||||||
const sign = signFor(row.category_type);
|
|
||||||
for (let m = 0; m < 12; m++) {
|
|
||||||
monthTotals[m] += row.months[m] * sign;
|
|
||||||
}
|
|
||||||
annualTotal += row.annual * sign;
|
|
||||||
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCols = 15; // category + prev year + annual + 12 months
|
const totalCols = 15; // category + prev year + annual + 12 months
|
||||||
|
|
||||||
|
|
@ -295,6 +291,77 @@ 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)]">
|
||||||
|
|
@ -327,58 +394,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{typeOrder.map((type) => {
|
{renderTypeSection("income")}
|
||||||
const group = grouped[type];
|
{renderTypeSection("expense")}
|
||||||
if (!group || group.length === 0) return null;
|
{/* Operating result (revenues − expenses), interleaved before the
|
||||||
const sign = signFor(type);
|
transfers section only when transfers exist — otherwise it
|
||||||
const leaves = group.filter((r) => !r.is_parent);
|
equals the net result below and would just be noise. */}
|
||||||
const sectionMonthTotals: number[] = Array(12).fill(0);
|
{results.hasTransfers &&
|
||||||
let sectionAnnualTotal = 0;
|
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
||||||
let sectionPrevYearTotal = 0;
|
{renderTypeSection("transfer")}
|
||||||
for (const row of leaves) {
|
{/* Bottom line: result after netting transfers. */}
|
||||||
for (let m = 0; m < 12; m++) {
|
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||||
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>
|
||||||
|
|
|
||||||
94
src/components/budget/budgetTableResults.test.ts
Normal file
94
src/components/budget/budgetTableResults.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
|
import { computeBudgetResults, sumLeavesForType, signForBudgetType } from "./budgetTableResults";
|
||||||
|
|
||||||
|
// Minimal leaf factory. `months` arrives as the positive-magnitude budgeted
|
||||||
|
// figure BudgetTable itself flips by type via `signFor` — same convention
|
||||||
|
// `sumLeavesForType` re-implements. `annual` always equals `sum(months)`
|
||||||
|
// (useBudget derives it that way; see useBudget.ts's `buildMonths`).
|
||||||
|
function leaf(
|
||||||
|
type: BudgetYearRow["category_type"],
|
||||||
|
months: number[],
|
||||||
|
previousYearTotal = 0,
|
||||||
|
overrides: Partial<BudgetYearRow> = {},
|
||||||
|
): BudgetYearRow {
|
||||||
|
return {
|
||||||
|
category_id: 1,
|
||||||
|
category_name: type,
|
||||||
|
category_color: "#000",
|
||||||
|
category_type: type,
|
||||||
|
parent_id: null,
|
||||||
|
is_parent: false,
|
||||||
|
depth: 0,
|
||||||
|
months,
|
||||||
|
annual: months.reduce((s, v) => s + v, 0),
|
||||||
|
previousYearTotal,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAT12 = (v: number) => Array(12).fill(v) as number[];
|
||||||
|
|
||||||
|
describe("computeBudgetResults — income-statement roll-up (Issue #278)", () => {
|
||||||
|
it("resultBefore = budgeted income − expense; resultNet adds transfers", () => {
|
||||||
|
const r = computeBudgetResults([
|
||||||
|
leaf("income", FLAT12(400)), // annual 4800
|
||||||
|
leaf("expense", FLAT12(300)), // annual 3600, stored positive, flipped to -300/mo
|
||||||
|
leaf("transfer", FLAT12(0)),
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.months[0]).toBe(100); // 400 − 300
|
||||||
|
expect(r.resultBefore.annual).toBe(1200); // 4800 − 3600
|
||||||
|
expect(r.resultNet.annual).toBe(1200); // balanced (0) transfer → unchanged
|
||||||
|
expect(r.hasTransfers).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
|
||||||
|
const r = computeBudgetResults([
|
||||||
|
leaf("income", FLAT12(1000)),
|
||||||
|
leaf("expense", FLAT12(700)),
|
||||||
|
leaf("transfer", FLAT12(50)), // uncategorized single leg, signed pass-through
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.annual).toBe(3600); // (1000 − 700) × 12
|
||||||
|
expect(r.resultNet.annual).toBe(4200); // resultBefore + transfer(50×12)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a deficit yields a negative net result and no transfer line when absent", () => {
|
||||||
|
const r = computeBudgetResults([leaf("income", FLAT12(200)), leaf("expense", FLAT12(250))]);
|
||||||
|
expect(r.resultNet.months[0]).toBe(-50);
|
||||||
|
expect(r.hasTransfers).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
||||||
|
const parent = leaf("expense", FLAT12(999), 0, { is_parent: true });
|
||||||
|
const r = computeBudgetResults([parent, leaf("expense", FLAT12(100))]);
|
||||||
|
expect(r.expense.annual).toBe(-1200); // only the leaf's 100 × 12 (flipped), not 999
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the previous-year actual through unsigned (already signed in the DB)", () => {
|
||||||
|
// Actual totals arrive pre-signed: expense negative, income positive.
|
||||||
|
const r = computeBudgetResults([
|
||||||
|
leaf("income", FLAT12(100), 1150),
|
||||||
|
leaf("expense", FLAT12(60), -650),
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.previousYearTotal).toBe(500); // 1150 + (−650), no re-sign
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sumLeavesForType filters by type and excludes parents", () => {
|
||||||
|
const t = sumLeavesForType(
|
||||||
|
[
|
||||||
|
leaf("expense", FLAT12(50)),
|
||||||
|
leaf("expense", FLAT12(999), 0, { is_parent: true }),
|
||||||
|
leaf("income", FLAT12(10)),
|
||||||
|
],
|
||||||
|
"expense",
|
||||||
|
);
|
||||||
|
expect(t.annual).toBe(-600); // 50 × 12, flipped negative
|
||||||
|
expect(t.months[0]).toBe(-50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signForBudgetType flips expense only", () => {
|
||||||
|
expect(signForBudgetType("expense")).toBe(-1);
|
||||||
|
expect(signForBudgetType("income")).toBe(1);
|
||||||
|
expect(signForBudgetType("transfer")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
src/components/budget/budgetTableResults.ts
Normal file
94
src/components/budget/budgetTableResults.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
|
|
||||||
|
export type BudgetSectionType = "income" | "expense" | "transfer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign multiplier applied to budgeted (months/annual) magnitudes. Budgeted
|
||||||
|
* amounts are entered/stored as positive magnitudes regardless of type and
|
||||||
|
* flipped for display by category type — mirrors `BudgetTable`'s `signFor`.
|
||||||
|
*/
|
||||||
|
export function signForBudgetType(type: BudgetSectionType): 1 | -1 {
|
||||||
|
return type === "expense" ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate of the budget grid's 3 comparable column groups, already signed:
|
||||||
|
* 12 monthly (budgeted) totals, the annual (budgeted) total, and the
|
||||||
|
* previous-year (actual) total.
|
||||||
|
*/
|
||||||
|
export interface BudgetTotals {
|
||||||
|
/** index 0-11 = Jan-Dec, signed budgeted total */
|
||||||
|
months: number[];
|
||||||
|
/** signed budgeted annual total */
|
||||||
|
annual: number;
|
||||||
|
/** signed actual total from the previous year (already signed in the DB) */
|
||||||
|
previousYearTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeroTotals(): BudgetTotals {
|
||||||
|
return { months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum every non-subtotal (leaf) row of a single type into one `BudgetTotals`.
|
||||||
|
*
|
||||||
|
* The type's sign is applied to the budgeted figures (`months`/`annual`),
|
||||||
|
* matching how `BudgetTable` displays them. `previousYearTotal` is NOT
|
||||||
|
* re-signed: it comes from `getActualTotalsForYear` (a plain signed
|
||||||
|
* `SUM(amount)` over the transactions table), already negative for expenses
|
||||||
|
* and ~0-net for balanced transfers — the same convention `compareResults.ts`
|
||||||
|
* relies on for its own previous-period figures.
|
||||||
|
*/
|
||||||
|
export function sumLeavesForType(rows: BudgetYearRow[], type: BudgetSectionType): BudgetTotals {
|
||||||
|
const sign = signForBudgetType(type);
|
||||||
|
const totals = zeroTotals();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.is_parent || row.category_type !== type) continue;
|
||||||
|
for (let m = 0; m < 12; m++) totals.months[m] += row.months[m] * sign;
|
||||||
|
totals.annual += row.annual * sign;
|
||||||
|
totals.previousYearTotal += row.previousYearTotal;
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Component-wise sum of two BudgetTotals (both already signed). */
|
||||||
|
function combine(a: BudgetTotals, b: BudgetTotals): BudgetTotals {
|
||||||
|
return {
|
||||||
|
months: a.months.map((v, i) => v + b.months[i]),
|
||||||
|
annual: a.annual + b.annual,
|
||||||
|
previousYearTotal: a.previousYearTotal + b.previousYearTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetResults {
|
||||||
|
income: BudgetTotals;
|
||||||
|
expense: BudgetTotals;
|
||||||
|
transfer: BudgetTotals;
|
||||||
|
/** Budgeted/actual revenues − expenses: the operating result, before transfers. */
|
||||||
|
resultBefore: BudgetTotals;
|
||||||
|
/** resultBefore + transfers: the bottom-line total. */
|
||||||
|
resultNet: BudgetTotals;
|
||||||
|
hasTransfers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Income-statement roll-up for the budget grid (Issue #278), mirroring
|
||||||
|
* `compareResults.ts`'s shape (see that module's doc comment for the sign
|
||||||
|
* convention this relies on).
|
||||||
|
*
|
||||||
|
* Operates on the flat `BudgetYearRow[]` the grid already renders (produced
|
||||||
|
* by `useBudget`) — LEAVES only (`is_parent === false`), so parent subtotal
|
||||||
|
* rows are never double-counted. All three column groups (previous-year
|
||||||
|
* actual, budgeted annual, budgeted monthly) are folded into the same result
|
||||||
|
* rows so "Résultat avant transferts" / "Résultat net" read consistently
|
||||||
|
* across every column of the grid.
|
||||||
|
*/
|
||||||
|
export function computeBudgetResults(rows: BudgetYearRow[]): BudgetResults {
|
||||||
|
const income = sumLeavesForType(rows, "income");
|
||||||
|
const expense = sumLeavesForType(rows, "expense");
|
||||||
|
const transfer = sumLeavesForType(rows, "transfer");
|
||||||
|
const resultBefore = combine(income, expense);
|
||||||
|
const resultNet = combine(resultBefore, transfer);
|
||||||
|
const hasTransfers = rows.some((r) => !r.is_parent && r.category_type === "transfer");
|
||||||
|
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
import { comboboxCategoriesForTarget } from "./migrationTargets";
|
||||||
|
import type { Category } from "../../shared/types";
|
||||||
import type {
|
import type {
|
||||||
MappingRow as MappingRowType,
|
MappingRow as MappingRowType,
|
||||||
ConfidenceBadge,
|
ConfidenceBadge,
|
||||||
|
|
@ -13,13 +15,27 @@ interface MappingRowProps {
|
||||||
/** Callback fired when the row is clicked — opens the preview panel. */
|
/** Callback fired when the row is clicked — opens the preview panel. */
|
||||||
onSelect: (v2CategoryId: number) => void;
|
onSelect: (v2CategoryId: number) => void;
|
||||||
/**
|
/**
|
||||||
* Called with the new v1 target id + name when the user resolves the row
|
* Called with the new v1 target id + name when the user picks a target via
|
||||||
* via the inline dropdown. The dropdown is only rendered for unresolved
|
* the inline type-ahead combobox. Editable on EVERY row (resolved or not),
|
||||||
* ("🟠 needs review") rows — resolved rows just show the target name.
|
* so a user can override an auto-detected target as well as resolve a
|
||||||
|
* "needs review" one.
|
||||||
*/
|
*/
|
||||||
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
|
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
|
||||||
/** Number of transactions currently attached to this v2 category. */
|
/** Number of transactions currently attached to this v2 category. */
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
/**
|
||||||
|
* v1 LEAF catalogue adapted to the `Category` shape the combobox expects.
|
||||||
|
* Computed ONCE by StepSimulate and shared across rows (perf). Leaves only —
|
||||||
|
* a transaction is never mapped to a grouping bucket.
|
||||||
|
*/
|
||||||
|
targetCategories: Category[];
|
||||||
|
/**
|
||||||
|
* Resolves a target id (possibly a non-leaf parent absent from the leaves-only
|
||||||
|
* `targetCategories`) to a display `Category`. Used to keep a low-confidence
|
||||||
|
* parent default like "Divertissement" (#1710) visible on its row without
|
||||||
|
* offering parents as new picks.
|
||||||
|
*/
|
||||||
|
resolveTarget: (id: number) => Category | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeClass(confidence: ConfidenceBadge): string {
|
function badgeClass(confidence: ConfidenceBadge): string {
|
||||||
|
|
@ -41,14 +57,21 @@ export default function MappingRow({
|
||||||
onSelect,
|
onSelect,
|
||||||
onResolve,
|
onResolve,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
|
targetCategories,
|
||||||
|
resolveTarget,
|
||||||
}: MappingRowProps) {
|
}: MappingRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getLeaves } = useCategoryTaxonomy();
|
|
||||||
|
|
||||||
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
|
// The picker lists leaves only. If this row's current target is a non-leaf
|
||||||
// list flat because the simulate row is narrow; the search box in step 2
|
// parent (a low-confidence default like "Divertissement" #1710), it is absent
|
||||||
// already helps users find a target by keyword.
|
// from that list and would blank the combobox input, so re-inject it for this
|
||||||
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
|
// row so it stays visible. It remains clickable, but selecting it is a no-op —
|
||||||
|
// handleTargetChange only resolves ids present in `targetCategories` (leaves) —
|
||||||
|
// so any pick that takes effect is a leaf. See comboboxCategoriesForTarget.
|
||||||
|
const comboboxCategories = useMemo(
|
||||||
|
() => comboboxCategoriesForTarget(targetCategories, row.v1TargetId ?? null, resolveTarget),
|
||||||
|
[targetCategories, row.v1TargetId, resolveTarget],
|
||||||
|
);
|
||||||
|
|
||||||
const badgeLabel = t(
|
const badgeLabel = t(
|
||||||
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
||||||
|
|
@ -59,12 +82,18 @@ export default function MappingRow({
|
||||||
|
|
||||||
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
||||||
|
|
||||||
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
|
// Fired when the user picks a v1 leaf in the type-ahead combobox. The
|
||||||
const v1TargetId = Number(ev.target.value);
|
// combobox only emits ids that exist in `targetCategories` (never null with
|
||||||
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
|
// our config), but we guard defensively. Resolving a "none" row bumps its
|
||||||
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
|
// confidence to "medium" via the reducer; editing an already-resolved row
|
||||||
if (!leaf) return;
|
// keeps its confidence unchanged (see RESOLVE_ROW).
|
||||||
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
|
const handleTargetChange = (v1TargetId: number | null) => {
|
||||||
|
if (v1TargetId === null) return;
|
||||||
|
const target = targetCategories.find((c) => c.id === v1TargetId);
|
||||||
|
if (!target) return;
|
||||||
|
const name = target.i18n_key
|
||||||
|
? t(target.i18n_key, { defaultValue: target.name })
|
||||||
|
: target.name;
|
||||||
onResolve(row.v2CategoryId, v1TargetId, name);
|
onResolve(row.v2CategoryId, v1TargetId, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -119,32 +148,26 @@ export default function MappingRow({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* v1 target (or picker) */}
|
{/* v1 target — editable type-ahead combobox on EVERY row. Stop click +
|
||||||
|
keydown from bubbling so interacting with the picker (typing spaces,
|
||||||
|
Enter to select) never triggers the row's select/preview handler. */}
|
||||||
<div
|
<div
|
||||||
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
|
className="col-span-5 flex items-center justify-end min-w-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{isUnresolved ? (
|
<div className="w-full max-w-[16rem]">
|
||||||
<select
|
<CategoryCombobox
|
||||||
value=""
|
categories={comboboxCategories}
|
||||||
onChange={handleResolveChange}
|
value={row.v1TargetId ?? null}
|
||||||
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
|
onChange={handleTargetChange}
|
||||||
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
compact
|
||||||
>
|
placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||||
<option value="" disabled>
|
ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
|
||||||
{t("categoriesSeed.migration.simulate.chooseTarget")}
|
category: row.v2CategoryName,
|
||||||
</option>
|
})}
|
||||||
{v1Leaves.map((leaf) => (
|
/>
|
||||||
<option key={leaf.id} value={leaf.id}>
|
</div>
|
||||||
{t(leaf.i18n_key, { defaultValue: leaf.name })}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-[var(--foreground)]">
|
|
||||||
{targetDisplayName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
|
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
|
||||||
import MappingRow from "./MappingRow";
|
import MappingRow from "./MappingRow";
|
||||||
import TransactionPreviewPanel from "./TransactionPreviewPanel";
|
import TransactionPreviewPanel from "./TransactionPreviewPanel";
|
||||||
|
import {
|
||||||
|
taxonomyToComboboxCategories,
|
||||||
|
findTaxonomyCategory,
|
||||||
|
} from "./migrationTargets";
|
||||||
|
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||||
import type {
|
import type {
|
||||||
MigrationPlan,
|
MigrationPlan,
|
||||||
MappingRow as MappingRowType,
|
MappingRow as MappingRowType,
|
||||||
|
|
@ -36,6 +41,24 @@ export default function StepSimulate({
|
||||||
onBack,
|
onBack,
|
||||||
}: StepSimulateProps) {
|
}: StepSimulateProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { taxonomy } = useCategoryTaxonomy();
|
||||||
|
|
||||||
|
// v1 LEAF catalogue adapted to the combobox `Category` shape. Computed once
|
||||||
|
// here (not per row) and shared across every MappingRow's target picker —
|
||||||
|
// only leaves are offered, so a transaction is never mapped to a grouping
|
||||||
|
// bucket.
|
||||||
|
const targetCategories = useMemo(
|
||||||
|
() => taxonomyToComboboxCategories(taxonomy.roots),
|
||||||
|
[taxonomy],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve a target id (possibly a non-leaf parent absent from the leaves-only
|
||||||
|
// list) to a display Category, so a row keeps showing a low-confidence parent
|
||||||
|
// default like "Divertissement" (#1710) without offering parents as picks.
|
||||||
|
const resolveTarget = useCallback(
|
||||||
|
(id: number) => findTaxonomyCategory(taxonomy.roots, id),
|
||||||
|
[taxonomy],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedRow = useMemo<MappingRowType | null>(() => {
|
const selectedRow = useMemo<MappingRowType | null>(() => {
|
||||||
if (selectedRowV2Id === null) return null;
|
if (selectedRowV2Id === null) return null;
|
||||||
|
|
@ -149,6 +172,8 @@ export default function StepSimulate({
|
||||||
transactionCount={
|
transactionCount={
|
||||||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||||
}
|
}
|
||||||
|
targetCategories={targetCategories}
|
||||||
|
resolveTarget={resolveTarget}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
177
src/components/categories-migration/migrationTargets.test.ts
Normal file
177
src/components/categories-migration/migrationTargets.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
taxonomyToComboboxCategories,
|
||||||
|
findTaxonomyCategory,
|
||||||
|
comboboxCategoriesForTarget,
|
||||||
|
} from "./migrationTargets";
|
||||||
|
import type { Category } from "../../shared/types";
|
||||||
|
import {
|
||||||
|
getTaxonomyV1,
|
||||||
|
type TaxonomyNode,
|
||||||
|
} from "../../services/categoryTaxonomyService";
|
||||||
|
|
||||||
|
function node(
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
children: TaxonomyNode[] = [],
|
||||||
|
type: "expense" | "income" | "transfer" = "expense",
|
||||||
|
): TaxonomyNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
i18n_key: `categoriesSeed.test.${id}`,
|
||||||
|
type,
|
||||||
|
color: "#123456",
|
||||||
|
sort_order: 1,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("taxonomyToComboboxCategories (leaves only)", () => {
|
||||||
|
it("returns [] for empty input", () => {
|
||||||
|
expect(taxonomyToComboboxCategories([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only leaves, dropping every intermediate parent, in DFS order", () => {
|
||||||
|
const roots = [
|
||||||
|
node(1000, "Revenus", [
|
||||||
|
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
|
||||||
|
]),
|
||||||
|
node(1100, "Alimentation", [node(1111, "Épicerie")]),
|
||||||
|
];
|
||||||
|
const out = taxonomyToComboboxCategories(roots);
|
||||||
|
// parents 1000/1010/1100 dropped; leaves kept in depth-first reading order
|
||||||
|
expect(out.map((c) => c.id)).toEqual([1011, 1012, 1111]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattens leaves: parent_id undefined + running sort_order (flat list)", () => {
|
||||||
|
const roots = [
|
||||||
|
node(1000, "Revenus", [
|
||||||
|
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
|
||||||
|
]),
|
||||||
|
node(1100, "Alimentation", [node(1111, "Épicerie")]),
|
||||||
|
];
|
||||||
|
const out = taxonomyToComboboxCategories(roots);
|
||||||
|
expect(out.every((c) => c.parent_id === undefined)).toBe(true);
|
||||||
|
expect(out.map((c) => c.sort_order)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks every returned row inputable (all leaves)", () => {
|
||||||
|
const roots = [
|
||||||
|
node(1700, "Loisirs", [
|
||||||
|
node(1710, "Divertissement", [node(1711, "Cinéma")]),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const out = taxonomyToComboboxCategories(roots);
|
||||||
|
expect(out.map((c) => c.id)).toEqual([1711]); // 1700 + 1710 parents dropped
|
||||||
|
expect(out.every((c) => c.is_inputable)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => {
|
||||||
|
const [c] = taxonomyToComboboxCategories([
|
||||||
|
node(1111, "Épicerie", [], "expense"),
|
||||||
|
]);
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
id: 1111,
|
||||||
|
name: "Épicerie",
|
||||||
|
type: "expense",
|
||||||
|
color: "#123456",
|
||||||
|
i18n_key: "categoriesSeed.test.1111",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
});
|
||||||
|
expect(typeof c.created_at).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adapts the real v1 taxonomy: 112 leaves, ids unique, NO non-leaf parent offered", () => {
|
||||||
|
const out = taxonomyToComboboxCategories(getTaxonomyV1().roots);
|
||||||
|
expect(out.length).toBe(112); // leaves only (was 150 incl. 38 parents)
|
||||||
|
const ids = new Set(out.map((c) => c.id));
|
||||||
|
expect(ids.size).toBe(out.length);
|
||||||
|
// The non-leaf parent 1710 "Divertissement" must NOT be a selectable option.
|
||||||
|
expect(out.find((c) => c.id === 1710)).toBeUndefined();
|
||||||
|
// A known leaf is present and inputable.
|
||||||
|
expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true);
|
||||||
|
expect(out.every((c) => c.is_inputable)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findTaxonomyCategory", () => {
|
||||||
|
const roots = [
|
||||||
|
node(1700, "Loisirs", [
|
||||||
|
node(1710, "Divertissement", [node(1711, "Cinéma")]),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("resolves a non-leaf parent by id (to display a current target)", () => {
|
||||||
|
const c = findTaxonomyCategory(roots, 1710);
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
id: 1710,
|
||||||
|
name: "Divertissement",
|
||||||
|
is_inputable: false,
|
||||||
|
});
|
||||||
|
expect(c!.parent_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a leaf too", () => {
|
||||||
|
expect(findTaxonomyCategory(roots, 1711)).toMatchObject({
|
||||||
|
id: 1711,
|
||||||
|
is_inputable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unknown id", () => {
|
||||||
|
expect(findTaxonomyCategory(roots, 9999)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the real low-confidence default target 1710 in the bundled taxonomy", () => {
|
||||||
|
const c = findTaxonomyCategory(getTaxonomyV1().roots, 1710);
|
||||||
|
expect(c).toMatchObject({ id: 1710, is_inputable: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comboboxCategoriesForTarget", () => {
|
||||||
|
const leaf = (id: number): Category => ({
|
||||||
|
id,
|
||||||
|
name: `Leaf ${id}`,
|
||||||
|
parent_id: undefined,
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
sort_order: id,
|
||||||
|
created_at: "",
|
||||||
|
});
|
||||||
|
const leaves = [leaf(1111), leaf(1121), leaf(1131)];
|
||||||
|
const parent1710: Category = {
|
||||||
|
id: 1710,
|
||||||
|
name: "Divertissement",
|
||||||
|
parent_id: undefined,
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: false,
|
||||||
|
sort_order: Number.MAX_SAFE_INTEGER,
|
||||||
|
created_at: "",
|
||||||
|
};
|
||||||
|
const resolveTarget = (id: number) => (id === 1710 ? parent1710 : null);
|
||||||
|
|
||||||
|
it("returns the leaves list unchanged (same reference) when target is null", () => {
|
||||||
|
expect(comboboxCategoriesForTarget(leaves, null, resolveTarget)).toBe(leaves);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the leaves list unchanged (same reference) when target is already a leaf", () => {
|
||||||
|
expect(comboboxCategoriesForTarget(leaves, 1121, resolveTarget)).toBe(leaves);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the resolved non-leaf parent when the target is a parent absent from leaves", () => {
|
||||||
|
const out = comboboxCategoriesForTarget(leaves, 1710, resolveTarget);
|
||||||
|
expect(out).toHaveLength(leaves.length + 1);
|
||||||
|
expect(out[out.length - 1]).toMatchObject({ id: 1710, is_inputable: false });
|
||||||
|
// originals preserved, in order
|
||||||
|
expect(out.slice(0, 3).map((c) => c.id)).toEqual([1111, 1121, 1131]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the leaves list unchanged when the non-leaf target cannot be resolved", () => {
|
||||||
|
// targetId absent from leaves AND resolver returns null (stale id) -> no injection
|
||||||
|
expect(comboboxCategoriesForTarget(leaves, 9999, resolveTarget)).toBe(leaves);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
src/components/categories-migration/migrationTargets.ts
Normal file
110
src/components/categories-migration/migrationTargets.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import type { Category } from "../../shared/types";
|
||||||
|
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the v1 taxonomy into `Category`-shaped rows for the migration target
|
||||||
|
* picker, keeping ONLY the leaves — the inputable end-categories a transaction
|
||||||
|
* can actually be filed under. Intermediate parents (grouping buckets) are
|
||||||
|
* dropped so the type-ahead offers real targets only, never a bucket.
|
||||||
|
*
|
||||||
|
* Leaves are emitted in depth-first reading order and carry a running
|
||||||
|
* `sort_order` with `parent_id` undefined, so `CategoryCombobox` renders them as
|
||||||
|
* one flat, un-indented list in taxonomy order (leaves stay grouped under their
|
||||||
|
* original parent) instead of re-scrambling them by the per-parent sort_order.
|
||||||
|
*
|
||||||
|
* Every leaf id is `is_inputable` in the DB after the migration, so any pick is
|
||||||
|
* FK-safe.
|
||||||
|
*
|
||||||
|
* Edge case: a low-confidence default can point a v2 category at a NON-leaf
|
||||||
|
* parent (e.g. v2 "Jeux, Films & Livres" -> v1 "Divertissement" #1710). Such a
|
||||||
|
* value is absent from this leaves-only list; `MappingRow` re-injects it for that
|
||||||
|
* single row (via `findTaxonomyCategory`) so the input still shows the current
|
||||||
|
* suggestion, while new picks stay leaves-only.
|
||||||
|
*
|
||||||
|
* Pure helper — no i18n, no DB, no React.
|
||||||
|
*/
|
||||||
|
export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] {
|
||||||
|
const out: Category[] = [];
|
||||||
|
let order = 0;
|
||||||
|
const walk = (node: TaxonomyNode): void => {
|
||||||
|
if (node.children.length === 0) {
|
||||||
|
out.push({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
parent_id: undefined,
|
||||||
|
color: node.color,
|
||||||
|
type: node.type,
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
sort_order: order++,
|
||||||
|
i18n_key: node.i18n_key,
|
||||||
|
created_at: "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const child of node.children) walk(child);
|
||||||
|
};
|
||||||
|
for (const root of roots) walk(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any taxonomy node (leaf OR parent) by id into the `Category` shape,
|
||||||
|
* for DISPLAY of a current non-leaf target that the leaves-only picker omits.
|
||||||
|
* `sort_order` is pushed to the end so, when injected, the node sorts last.
|
||||||
|
* Returns null when the id is absent from the taxonomy. Pure helper.
|
||||||
|
*/
|
||||||
|
export function findTaxonomyCategory(
|
||||||
|
roots: TaxonomyNode[],
|
||||||
|
id: number,
|
||||||
|
): Category | null {
|
||||||
|
const stack: TaxonomyNode[] = [...roots];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const node = stack.pop()!;
|
||||||
|
if (node.id === id) {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
parent_id: undefined,
|
||||||
|
color: node.color,
|
||||||
|
type: node.type,
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: node.children.length === 0,
|
||||||
|
sort_order: Number.MAX_SAFE_INTEGER,
|
||||||
|
i18n_key: node.i18n_key,
|
||||||
|
created_at: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const child of node.children) stack.push(child);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the target-picker option list for one migration row.
|
||||||
|
*
|
||||||
|
* Returns the leaves-only `leafOptions` unchanged when the row's current target
|
||||||
|
* is null or already a leaf (the common case — same array reference, so the
|
||||||
|
* combobox never re-renders needlessly).
|
||||||
|
*
|
||||||
|
* When the target is a NON-leaf parent absent from the leaves list (a
|
||||||
|
* low-confidence default like "Divertissement" #1710), the resolved parent is
|
||||||
|
* appended so the combobox can DISPLAY it as the current value. It stays visible
|
||||||
|
* (and, since `CategoryCombobox` renders every option, clickable) but selecting
|
||||||
|
* it is a no-op: the row's resolve handler only accepts ids present in the
|
||||||
|
* leaves list, so re-picking the parent changes nothing and the user is steered
|
||||||
|
* to a leaf. New picks that take effect are therefore always leaves.
|
||||||
|
*
|
||||||
|
* Pure helper — no React, no i18n, no DB.
|
||||||
|
*/
|
||||||
|
export function comboboxCategoriesForTarget(
|
||||||
|
leafOptions: Category[],
|
||||||
|
targetId: number | null,
|
||||||
|
resolveTarget: (id: number) => Category | null,
|
||||||
|
): Category[] {
|
||||||
|
if (targetId == null || leafOptions.some((c) => c.id === targetId)) {
|
||||||
|
return leafOptions;
|
||||||
|
}
|
||||||
|
const current = resolveTarget(targetId);
|
||||||
|
return current ? [...leafOptions, current] : leafOptions;
|
||||||
|
}
|
||||||
62
src/components/dashboard/NetWorthTile.tsx
Normal file
62
src/components/dashboard/NetWorthTile.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// NetWorthTile — Dashboard KPI-row tile showing the Bilan's latest net worth.
|
||||||
|
//
|
||||||
|
// Issue #279. Deliberately NOT `HubNetBalanceTile` (that one is the reports
|
||||||
|
// hub's transaction-P&L net balance — a different metric). This tile mirrors
|
||||||
|
// `BalanceOverviewCard`'s "latest total" figure and reuses its exact i18n
|
||||||
|
// copy (`balance.overview.latestTotal` / `asOf`) so the label is unambiguous
|
||||||
|
// next to the transactional KPI cards and the "import sources" account
|
||||||
|
// filter — same words as the Bilan page itself, never "balance"/"solde".
|
||||||
|
//
|
||||||
|
// Renders nothing when `data.visible` is false (no balance account yet, or
|
||||||
|
// accounts but no snapshot yet) — never a misleading "$0" tile.
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Wallet } from "lucide-react";
|
||||||
|
import type { NetWorthTileData } from "../../services/dashboardService";
|
||||||
|
|
||||||
|
export interface NetWorthTileProps {
|
||||||
|
data: NetWorthTileData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NetWorthTile({ data }: NetWorthTileProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
if (!data.visible || data.total === null || data.asOfDate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
||||||
|
const formattedDate = new Date(data.asOfDate).toLocaleDateString(dateLocale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/balance"
|
||||||
|
data-kpi="net-worth"
|
||||||
|
className="flex-1 min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3 hover:border-[var(--primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)] flex items-center gap-1.5">
|
||||||
|
<Wallet size={14} aria-hidden="true" />
|
||||||
|
<span>{t("balance.overview.latestTotal")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
|
||||||
|
{formatCurrency(data.total, i18n.language)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)] pt-1 border-t border-[var(--border)]">
|
||||||
|
{t("balance.overview.asOf", { date: formattedDate })}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown } from "lucide-react";
|
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } 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", {
|
||||||
|
|
@ -26,6 +29,17 @@ 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(() => {
|
||||||
|
|
@ -41,6 +55,12 @@ 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)]">
|
||||||
|
|
@ -49,8 +69,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group rows by type for section headers
|
// Group rows into contiguous type sections (the service already type-sorts:
|
||||||
type SectionType = "expense" | "income" | "transfer";
|
// income → expense → transfer, Issue #277).
|
||||||
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
||||||
const typeLabels: Record<SectionType, string> = {
|
const typeLabels: Record<SectionType, string> = {
|
||||||
expense: t("budget.expenses"),
|
expense: t("budget.expenses"),
|
||||||
|
|
@ -72,25 +92,176 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
sections[sections.length - 1].rows.push(row);
|
sections[sections.length - 1].rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grand totals (leaf rows only)
|
// Income-statement result lines (Issue #277): income + expense (expense
|
||||||
const leaves = data.filter((r) => !r.is_parent);
|
// already arrives signed negative in BudgetVsActualRow — see
|
||||||
const totals = leaves.reduce(
|
// budgetVsActualResults.ts), then the net after adding transfers. Replaces
|
||||||
(acc, r) => ({
|
// the old flat grand total row.
|
||||||
monthActual: acc.monthActual + r.monthActual,
|
const results = computeResults(data);
|
||||||
monthBudget: acc.monthBudget + r.monthBudget,
|
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
|
||||||
monthVariation: acc.monthVariation + r.monthVariation,
|
const transferSection = sections.find((s) => s.type === "transfer");
|
||||||
ytdActual: acc.ytdActual + r.ytdActual,
|
|
||||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
|
||||||
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
const sectionTotals = sumLeaves(section.rows);
|
||||||
}),
|
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
|
||||||
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget);
|
||||||
);
|
return (
|
||||||
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
<Fragment key={section.type}>
|
||||||
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
<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(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 px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
{hasGroups && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||||
|
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<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"
|
||||||
|
|
@ -141,130 +312,15 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sections.map((section) => {
|
{nonTransferSections.map(renderSection)}
|
||||||
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
|
{/* Operating result (income + expense), shown before the transfers
|
||||||
const sectionTotals = sectionLeaves.reduce(
|
section only when transfers exist — otherwise it equals the net
|
||||||
(acc, r) => ({
|
total below and would just be noise (mirrors ComparePeriodTable). */}
|
||||||
monthActual: acc.monthActual + r.monthActual,
|
{results.hasTransfers &&
|
||||||
monthBudget: acc.monthBudget + r.monthBudget,
|
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
||||||
monthVariation: acc.monthVariation + r.monthVariation,
|
{transferSection && renderSection(transferSection)}
|
||||||
ytdActual: acc.ytdActual + r.ytdActual,
|
{/* Bottom line: result after adding transfers. */}
|
||||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
|
import { Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { CategoryOverTimeData } from "../../shared/types";
|
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||||
|
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
||||||
|
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);
|
||||||
|
|
@ -10,14 +20,40 @@ function formatMonth(month: string): string {
|
||||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryOverTimeTableProps {
|
/** Colours a result figure by sign: surplus green, deficit red, zero neutral. */
|
||||||
data: CategoryOverTimeData;
|
function resultColor(value: number): string {
|
||||||
hiddenCategories?: Set<string>;
|
if (value > 0) return "var(--positive, #10b981)";
|
||||||
|
if (value < 0) return "var(--negative, #ef4444)";
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
|
const SECTION_LABEL_KEY: Record<OverTimeType, string> = {
|
||||||
|
income: "reports.compare.sections.income",
|
||||||
|
expense: "reports.compare.sections.expenses",
|
||||||
|
transfer: "reports.compare.sections.transfers",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
|
||||||
|
income: "reports.compare.totalIncome",
|
||||||
|
expense: "reports.compare.totalExpenses",
|
||||||
|
transfer: "reports.compare.totalTransfers",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CategoryOverTimeTableProps {
|
||||||
|
data: CategoryOverTimeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryOverTimeTable({ data }: 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)]">
|
||||||
|
|
@ -26,14 +62,163 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleCategories = hiddenCategories?.size
|
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
|
||||||
? data.categories.filter((name) => !hiddenCategories.has(name))
|
// the result lines are exact, and homonym categories never collide.
|
||||||
: data.categories;
|
const analysis = computeOverTimeResults(data);
|
||||||
|
const { months, hasTransfers, net, beforeTransfers } = analysis;
|
||||||
|
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
|
||||||
|
const colSpan = months.length + 2;
|
||||||
|
|
||||||
const months = data.data.map((d) => d.month);
|
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">
|
||||||
|
|
@ -52,57 +237,14 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleCategories.map((category) => {
|
{nonTransferSections.map(renderSection)}
|
||||||
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
|
{/* Operating result (revenues − expenses), interleaved before the
|
||||||
return (
|
transfers section only when transfers exist — otherwise it equals
|
||||||
<tr key={category} className="border-b border-[var(--border)]/50">
|
the net total below and would just be noise. */}
|
||||||
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
|
||||||
<span className="flex items-center gap-2">
|
{transferSection && renderSection(transferSection)}
|
||||||
<span
|
{/* Bottom line: result after netting transfers. */}
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
{renderResultRow("reports.compare.resultNet", net, true)}
|
||||||
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">
|
|
||||||
{value ? cadFormatter(value) : "—"}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(rowTotal)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
|
|
||||||
{months.map((month) => {
|
|
||||||
const monthData = data.data.find((d) => d.month === month);
|
|
||||||
const monthTotal = visibleCategories.reduce(
|
|
||||||
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<td key={month} className="text-right px-3 py-3">
|
|
||||||
{cadFormatter(monthTotal)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(
|
|
||||||
visibleCategories.reduce(
|
|
||||||
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ 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 }: CompareBudgetViewProps) {
|
export default function CompareBudgetView({ year, month, accountIds }: 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);
|
||||||
|
|
@ -17,7 +21,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
getBudgetVsActualData(year, month)
|
getBudgetVsActualData(year, month, accountIds)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setRows(data);
|
if (!cancelled) setRows(data);
|
||||||
})
|
})
|
||||||
|
|
@ -27,7 +31,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [year, month]);
|
}, [year, month, accountIds]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,14 @@ export default function ComparePeriodChart({
|
||||||
}: ComparePeriodChartProps) {
|
}: ComparePeriodChartProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
if (rows.length === 0) {
|
// The chart stays a spending view: the income-statement result (revenues +
|
||||||
return (
|
// result lines, Issue #253) lives in the table, so keep only expense leaves
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
// here — mixing revenue bars into the same axis would misread. Drop subtotal
|
||||||
{t("reports.empty.noData")}
|
// (is_parent) rows too so a group is not double-counted against its own
|
||||||
</div>
|
// leaves. Sorted by current amount (largest spending first) for a top-down
|
||||||
);
|
// read of the biggest movers.
|
||||||
}
|
const chartData = rows
|
||||||
|
.filter((r) => !r.is_parent && (r.category_type ?? "expense") === "expense")
|
||||||
// Sort by current-period amount (largest spending first) so the user's eye
|
|
||||||
// lands on the biggest categories, then reverse so the biggest appears at
|
|
||||||
// the top of the vertical bar chart.
|
|
||||||
const chartData = [...rows]
|
|
||||||
.sort((a, b) => b.currentAmount - a.currentAmount)
|
.sort((a, b) => b.currentAmount - a.currentAmount)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
name: r.categoryName,
|
name: r.categoryName,
|
||||||
|
|
@ -52,6 +48,16 @@ export default function ComparePeriodChart({
|
||||||
color: r.categoryColor,
|
color: r.categoryColor,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Empty when there is no data at all, or when the period is pure
|
||||||
|
// income/transfers (no expense bars to draw) — avoid rendering bare axes.
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("reports.empty.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const previousFill = "var(--muted-foreground)";
|
const previousFill = "var(--muted-foreground)";
|
||||||
const currentFill = "var(--primary)";
|
const currentFill = "var(--primary)";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
|
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 type { CategoryDelta } from "../../shared/types";
|
import type { CategoryDelta } from "../../shared/types";
|
||||||
|
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";
|
||||||
|
|
||||||
export interface ComparePeriodTableProps {
|
export interface ComparePeriodTableProps {
|
||||||
rows: CategoryDelta[];
|
rows: CategoryDelta[];
|
||||||
|
|
@ -30,24 +36,41 @@ function formatSignedCurrency(amount: number, language: string): string {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPct(pct: number | null, language: string): string {
|
function formatPct(pctValue: number | null, language: string): string {
|
||||||
if (pct === null) return "—";
|
if (pctValue === null) return "—";
|
||||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
style: "percent",
|
style: "percent",
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
signDisplay: "always",
|
signDisplay: "always",
|
||||||
}).format(pct / 100);
|
}).format(pctValue / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function variationColor(value: number): string {
|
/**
|
||||||
// Compare report deals with expenses (abs values): spending more is negative
|
* Delta colour, direction-aware (Issue #253). Expenses/transfers keep the
|
||||||
// for the user, spending less is positive. Mirror that colour convention
|
* spending convention (increase → red, decrease → green); income and the
|
||||||
// consistently so the eye parses the delta sign at a glance.
|
* result lines invert it (higher is better → green). Also used to colour a
|
||||||
if (value > 0) return "var(--negative, #ef4444)";
|
* result *amount* by sign: a surplus is green, a deficit red.
|
||||||
if (value < 0) return "var(--positive, #10b981)";
|
*/
|
||||||
return "";
|
function deltaColor(value: number, higherIsBetter: boolean): string {
|
||||||
|
if (value === 0) return "";
|
||||||
|
const good = higherIsBetter ? value > 0 : value < 0;
|
||||||
|
return good ? "var(--positive, #10b981)" : "var(--negative, #ef4444)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
export default function ComparePeriodTable({
|
export default function ComparePeriodTable({
|
||||||
rows,
|
rows,
|
||||||
previousLabel,
|
previousLabel,
|
||||||
|
|
@ -56,31 +79,291 @@ export default function ComparePeriodTable({
|
||||||
cumulativeCurrentLabel,
|
cumulativeCurrentLabel,
|
||||||
}: ComparePeriodTableProps) {
|
}: ComparePeriodTableProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
|
|
||||||
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === null ? true : stored === "top";
|
||||||
|
});
|
||||||
|
const toggleSubtotals = () => {
|
||||||
|
setSubtotalsOnTop((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
|
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
|
||||||
|
|
||||||
// Totals across all rows (there is no parent/child structure in compare mode).
|
// Group rows into contiguous type sections (the service already type-sorts:
|
||||||
const totals = rows.reduce(
|
// income → expense → transfer).
|
||||||
(acc, r) => ({
|
const sectionLabels: Record<SectionType, string> = {
|
||||||
monthCurrent: acc.monthCurrent + r.currentAmount,
|
expense: t("reports.compare.sections.expenses"),
|
||||||
monthPrevious: acc.monthPrevious + r.previousAmount,
|
income: t("reports.compare.sections.income"),
|
||||||
monthDelta: acc.monthDelta + r.deltaAbs,
|
transfer: t("reports.compare.sections.transfers"),
|
||||||
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
|
};
|
||||||
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
|
const sectionTotalKeys: Record<SectionType, string> = {
|
||||||
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
|
expense: "reports.compare.totalExpenses",
|
||||||
}),
|
income: "reports.compare.totalIncome",
|
||||||
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
|
transfer: "reports.compare.totalTransfers",
|
||||||
);
|
};
|
||||||
const totalMonthPct =
|
const sections: { type: SectionType; rows: CategoryDelta[] }[] = [];
|
||||||
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null;
|
let currentType: SectionType | null = null;
|
||||||
const totalYtdPct =
|
for (const row of rows) {
|
||||||
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null;
|
const type = (row.category_type ?? "expense") as SectionType;
|
||||||
|
if (type !== currentType) {
|
||||||
|
currentType = type;
|
||||||
|
sections.push({ type, rows: [] });
|
||||||
|
}
|
||||||
|
sections[sections.length - 1].rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Income-statement result lines (Issue #253): revenues − expenses, then the
|
||||||
|
// net after transfers. Replaces the old flat grand total, which is meaningless
|
||||||
|
// once revenues and (ABS) expenses share one table.
|
||||||
|
const results = computeResults(rows);
|
||||||
|
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
|
||||||
|
const transferSection = sections.find((s) => s.type === "transfer");
|
||||||
|
|
||||||
|
const renderSection = (section: { type: SectionType; rows: CategoryDelta[] }) => {
|
||||||
|
// Income section: an increase is good (green); expenses/transfers keep the
|
||||||
|
// spending convention (increase → red).
|
||||||
|
const higherIsBetter = section.type === "income";
|
||||||
|
const sectionTotals = sumLeaves(section.rows);
|
||||||
|
return (
|
||||||
|
<Fragment key={section.type}>
|
||||||
|
<tr className="bg-[var(--muted)]">
|
||||||
|
<td
|
||||||
|
colSpan={COL_COUNT}
|
||||||
|
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{sectionLabels[section.type]}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
||||||
|
const isParent = row.is_parent ?? false;
|
||||||
|
const depth = row.depth ?? 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.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>
|
||||||
|
{/* Monthly block */}
|
||||||
|
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||||
|
{formatCurrency(row.currentAmount, lang)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-1.5 tabular-nums">
|
||||||
|
{formatCurrency(row.previousAmount, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-1.5 tabular-nums font-medium"
|
||||||
|
style={{ color: deltaColor(row.deltaAbs, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatSignedCurrency(row.deltaAbs, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-1.5 tabular-nums"
|
||||||
|
style={{ color: deltaColor(row.deltaAbs, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatPct(row.deltaPct, lang)}
|
||||||
|
</td>
|
||||||
|
{/* Cumulative YTD block */}
|
||||||
|
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||||
|
{formatCurrency(row.cumulativeCurrentAmount, lang)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-1.5 tabular-nums">
|
||||||
|
{formatCurrency(row.cumulativePreviousAmount, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-1.5 tabular-nums font-medium"
|
||||||
|
style={{ color: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-1.5 tabular-nums"
|
||||||
|
style={{ color: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatPct(row.cumulativeDeltaPct, lang)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Section net total */}
|
||||||
|
<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(sectionTotalKeys[section.type])}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||||
|
{formatCurrency(sectionTotals.monthCurrent, lang)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5 tabular-nums">
|
||||||
|
{formatCurrency(sectionTotals.monthPrevious, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-2.5 tabular-nums"
|
||||||
|
style={{ color: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-2.5 tabular-nums"
|
||||||
|
style={{ color: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||||
|
{formatCurrency(sectionTotals.ytdCurrent, lang)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5 tabular-nums">
|
||||||
|
{formatCurrency(sectionTotals.ytdPrevious, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-2.5 tabular-nums"
|
||||||
|
style={{ color: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-2.5 tabular-nums"
|
||||||
|
style={{ color: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
|
||||||
|
>
|
||||||
|
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A result line (before-transfers subtotal or the net total). Higher is always
|
||||||
|
// better here: amounts are coloured by sign (surplus green / deficit red) and
|
||||||
|
// deltas by improvement.
|
||||||
|
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 tabular-nums";
|
||||||
|
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`}
|
||||||
|
style={{ color: deltaColor(tot.monthCurrent, true) }}
|
||||||
|
>
|
||||||
|
{formatCurrency(tot.monthCurrent, lang)}
|
||||||
|
</td>
|
||||||
|
<td className={cell} style={{ color: deltaColor(tot.monthPrevious, true) }}>
|
||||||
|
{formatCurrency(tot.monthPrevious, lang)}
|
||||||
|
</td>
|
||||||
|
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
|
||||||
|
{formatSignedCurrency(tot.monthDelta, lang)}
|
||||||
|
</td>
|
||||||
|
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
|
||||||
|
{formatPct(pct(tot.monthDelta, tot.monthPrevious), lang)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`${cell} border-l border-[var(--border)]/50`}
|
||||||
|
style={{ color: deltaColor(tot.ytdCurrent, true) }}
|
||||||
|
>
|
||||||
|
{formatCurrency(tot.ytdCurrent, lang)}
|
||||||
|
</td>
|
||||||
|
<td className={cell} style={{ color: deltaColor(tot.ytdPrevious, true) }}>
|
||||||
|
{formatCurrency(tot.ytdPrevious, lang)}
|
||||||
|
</td>
|
||||||
|
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
|
||||||
|
{formatSignedCurrency(tot.ytdDelta, lang)}
|
||||||
|
</td>
|
||||||
|
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
|
||||||
|
{formatPct(pct(tot.ytdDelta, tot.ytdPrevious), lang)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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)]">
|
||||||
|
{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
|
||||||
|
onClick={toggleSubtotals}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 z-20">
|
<thead className="sticky top-0 z-20">
|
||||||
|
|
@ -138,111 +421,25 @@ export default function ComparePeriodTable({
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colSpan={COL_COUNT} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
||||||
colSpan={9}
|
|
||||||
className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"
|
|
||||||
>
|
|
||||||
{t("reports.empty.noData")}
|
{t("reports.empty.noData")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{rows.map((row) => (
|
{nonTransferSections.map(renderSection)}
|
||||||
<tr
|
{/* Operating result (revenues − expenses), shown before the
|
||||||
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
|
transfers section only when transfers exist — otherwise it
|
||||||
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
|
equals the net total below and would just be noise. */}
|
||||||
>
|
{results.hasTransfers &&
|
||||||
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
renderResultRow(
|
||||||
<span className="flex items-center gap-2">
|
"reports.compare.resultBeforeTransfers",
|
||||||
<span
|
results.resultBefore,
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
false,
|
||||||
style={{ backgroundColor: row.categoryColor }}
|
)}
|
||||||
/>
|
{transferSection && renderSection(transferSection)}
|
||||||
{row.categoryName}
|
{/* Bottom line: result after netting transfers. */}
|
||||||
</span>
|
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||||
</td>
|
|
||||||
{/* Monthly block */}
|
|
||||||
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{formatCurrency(row.currentAmount, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-1.5 tabular-nums">
|
|
||||||
{formatCurrency(row.previousAmount, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-1.5 tabular-nums font-medium"
|
|
||||||
style={{ color: variationColor(row.deltaAbs) }}
|
|
||||||
>
|
|
||||||
{formatSignedCurrency(row.deltaAbs, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-1.5 tabular-nums"
|
|
||||||
style={{ color: variationColor(row.deltaAbs) }}
|
|
||||||
>
|
|
||||||
{formatPct(row.deltaPct, i18n.language)}
|
|
||||||
</td>
|
|
||||||
{/* Cumulative YTD block */}
|
|
||||||
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-1.5 tabular-nums">
|
|
||||||
{formatCurrency(row.cumulativePreviousAmount, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-1.5 tabular-nums font-medium"
|
|
||||||
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
|
|
||||||
>
|
|
||||||
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-1.5 tabular-nums"
|
|
||||||
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
|
|
||||||
>
|
|
||||||
{formatPct(row.cumulativeDeltaPct, i18n.language)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{/* Grand totals row */}
|
|
||||||
<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("reports.compare.totalRow")}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{formatCurrency(totals.monthCurrent, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-3 tabular-nums">
|
|
||||||
{formatCurrency(totals.monthPrevious, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
|
||||||
style={{ color: variationColor(totals.monthDelta) }}
|
|
||||||
>
|
|
||||||
{formatSignedCurrency(totals.monthDelta, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
|
||||||
style={{ color: variationColor(totals.monthDelta) }}
|
|
||||||
>
|
|
||||||
{formatPct(totalMonthPct, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{formatCurrency(totals.ytdCurrent, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-3 tabular-nums">
|
|
||||||
{formatCurrency(totals.ytdPrevious, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
|
||||||
style={{ color: variationColor(totals.ytdDelta) }}
|
|
||||||
>
|
|
||||||
{formatSignedCurrency(totals.ytdDelta, i18n.language)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
|
||||||
style={{ color: variationColor(totals.ytdDelta) }}
|
|
||||||
>
|
|
||||||
{formatPct(totalYtdPct, i18n.language)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
199
src/components/reports/FilterPanel.test.tsx
Normal file
199
src/components/reports/FilterPanel.test.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
// 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
src/components/reports/FilterPanel.tsx
Normal file
93
src/components/reports/FilterPanel.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/reports/budgetVsActualResults.test.ts
Normal file
91
src/components/reports/budgetVsActualResults.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
|
import { computeResults, sumLeaves } from "./budgetVsActualResults";
|
||||||
|
|
||||||
|
// Minimal leaf factory. Amounts arrive from getBudgetVsActualData already in
|
||||||
|
// the per-type sign convention: expense actual/budget are negative (raw,
|
||||||
|
// unsigned SUM(transactions.amount) — expenses are stored negative — and the
|
||||||
|
// user-entered budget negated to match via signFor); income/transfer stay
|
||||||
|
// however they naturally sum (positive for income, signed net for transfer).
|
||||||
|
function leaf(
|
||||||
|
type: "expense" | "income" | "transfer",
|
||||||
|
monthActual: number,
|
||||||
|
monthBudget: number,
|
||||||
|
ytdActual = monthActual,
|
||||||
|
ytdBudget = monthBudget,
|
||||||
|
): BudgetVsActualRow {
|
||||||
|
return {
|
||||||
|
category_id: 1,
|
||||||
|
category_name: type,
|
||||||
|
category_color: "#000",
|
||||||
|
category_type: type,
|
||||||
|
parent_id: null,
|
||||||
|
is_parent: false,
|
||||||
|
monthActual,
|
||||||
|
monthBudget,
|
||||||
|
monthVariation: monthActual - monthBudget,
|
||||||
|
monthVariationPct: monthBudget !== 0 ? (monthActual - monthBudget) / Math.abs(monthBudget) : null,
|
||||||
|
ytdActual,
|
||||||
|
ytdBudget,
|
||||||
|
ytdVariation: ytdActual - ytdBudget,
|
||||||
|
ytdVariationPct: ytdBudget !== 0 ? (ytdActual - ytdBudget) / Math.abs(ytdBudget) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeResults — income-statement roll-up (Issue #277)", () => {
|
||||||
|
it("resultBefore = income + expense (expense already signed negative)", () => {
|
||||||
|
const r = computeResults([
|
||||||
|
leaf("income", 3000, 2800),
|
||||||
|
leaf("expense", -1800, -2000), // spent 1800, budgeted to spend 2000
|
||||||
|
leaf("transfer", 0, 0),
|
||||||
|
]);
|
||||||
|
// Operating result: 3000 + (-1800) = 1200 actual; 2800 + (-2000) = 800 budgeted.
|
||||||
|
expect(r.resultBefore.monthActual).toBe(1200);
|
||||||
|
expect(r.resultBefore.monthBudget).toBe(800);
|
||||||
|
expect(r.resultBefore.monthVariation).toBe(400);
|
||||||
|
// Balanced transfer (0) → net equals the operating result.
|
||||||
|
expect(r.resultNet.monthActual).toBe(1200);
|
||||||
|
expect(r.hasTransfers).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-zero transfer moves resultNet away from resultBefore", () => {
|
||||||
|
const r = computeResults([
|
||||||
|
leaf("income", 4000, 4000),
|
||||||
|
leaf("expense", -3000, -3000),
|
||||||
|
leaf("transfer", -200, 0), // a single categorised leg
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.monthActual).toBe(1000);
|
||||||
|
// Net folds the residual transfer in: 1000 + (−200) = 800.
|
||||||
|
expect(r.resultNet.monthActual).toBe(800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a deficit yields a negative net result and no transfer line", () => {
|
||||||
|
const r = computeResults([leaf("income", 2000, 2000), leaf("expense", -2500, -2400)]);
|
||||||
|
expect(r.resultNet.monthActual).toBe(-500);
|
||||||
|
expect(r.hasTransfers).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
||||||
|
const parent = { ...leaf("expense", -999, -999), is_parent: true };
|
||||||
|
const r = computeResults([parent, leaf("expense", -100, -80)]);
|
||||||
|
expect(r.expense.monthActual).toBe(-100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the YTD figures through the same arithmetic", () => {
|
||||||
|
const r = computeResults([
|
||||||
|
leaf("income", 100, 100, 1200, 1150),
|
||||||
|
leaf("expense", -60, -60, -700, -650),
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.ytdActual).toBe(500);
|
||||||
|
expect(r.resultBefore.ytdBudget).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sumLeaves adds only leaves", () => {
|
||||||
|
const t = sumLeaves([
|
||||||
|
leaf("expense", -100, -50),
|
||||||
|
{ ...leaf("expense", -999, -999), is_parent: true },
|
||||||
|
]);
|
||||||
|
expect(t.monthActual).toBe(-100);
|
||||||
|
expect(t.monthBudget).toBe(-50);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/components/reports/budgetVsActualResults.ts
Normal file
95
src/components/reports/budgetVsActualResults.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
|
|
||||||
|
export type SectionType = "expense" | "income" | "transfer";
|
||||||
|
|
||||||
|
/** Aggregate of the 6 budget-vs-actual figures across a set of leaf rows. */
|
||||||
|
export interface Totals {
|
||||||
|
monthActual: number;
|
||||||
|
monthBudget: number;
|
||||||
|
monthVariation: number;
|
||||||
|
ytdActual: number;
|
||||||
|
ytdBudget: number;
|
||||||
|
ytdVariation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO: Totals = {
|
||||||
|
monthActual: 0,
|
||||||
|
monthBudget: 0,
|
||||||
|
monthVariation: 0,
|
||||||
|
ytdActual: 0,
|
||||||
|
ytdBudget: 0,
|
||||||
|
ytdVariation: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sum every non-subtotal (leaf) row into a single Totals. */
|
||||||
|
export function sumLeaves(rows: BudgetVsActualRow[]): Totals {
|
||||||
|
return rows
|
||||||
|
.filter((r) => !r.is_parent)
|
||||||
|
.reduce<Totals>(
|
||||||
|
(acc, r) => ({
|
||||||
|
monthActual: acc.monthActual + r.monthActual,
|
||||||
|
monthBudget: acc.monthBudget + r.monthBudget,
|
||||||
|
monthVariation: acc.monthVariation + r.monthVariation,
|
||||||
|
ytdActual: acc.ytdActual + r.ytdActual,
|
||||||
|
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
||||||
|
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
||||||
|
}),
|
||||||
|
{ ...ZERO },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variation as a fraction of budget (null when budget is 0 — mirrors the
|
||||||
|
* row-level `*VariationPct` fields computed in `getBudgetVsActualData`). */
|
||||||
|
export function pct(variation: number, budget: number): number | null {
|
||||||
|
return budget !== 0 ? variation / Math.abs(budget) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Component-wise a + b across the 6 figures. */
|
||||||
|
function add(a: Totals, b: Totals): Totals {
|
||||||
|
return {
|
||||||
|
monthActual: a.monthActual + b.monthActual,
|
||||||
|
monthBudget: a.monthBudget + b.monthBudget,
|
||||||
|
monthVariation: a.monthVariation + b.monthVariation,
|
||||||
|
ytdActual: a.ytdActual + b.ytdActual,
|
||||||
|
ytdBudget: a.ytdBudget + b.ytdBudget,
|
||||||
|
ytdVariation: a.ytdVariation + b.ytdVariation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetVsActualResults {
|
||||||
|
income: Totals;
|
||||||
|
expense: Totals;
|
||||||
|
transfer: Totals;
|
||||||
|
/** Income + expense: the operating result, before transfers. */
|
||||||
|
resultBefore: Totals;
|
||||||
|
/** resultBefore + transfers: the bottom-line total. */
|
||||||
|
resultNet: Totals;
|
||||||
|
hasTransfers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Income-statement roll-up for the budget-vs-actual report (Issue #277).
|
||||||
|
*
|
||||||
|
* Mirrors `compareResults.computeResults`, with one key sign difference: BVA
|
||||||
|
* amounts already arrive signed the "accounting" way rather than ABS'd to
|
||||||
|
* positive magnitudes. `getBudgetVsActualData` computes `monthActual`/
|
||||||
|
* `ytdActual` as the raw (unsigned-by-us) `SUM(transactions.amount)` — negative
|
||||||
|
* for expense categories, since expense transactions are stored as negative
|
||||||
|
* amounts throughout this app (see reportService/dashboardService `amount < 0`
|
||||||
|
* conventions) — and negates the user-entered (positive) budget figure to
|
||||||
|
* match via `signFor`. So expense totals here are already ≤ 0, and the
|
||||||
|
* operating result is a straight ADD of income + expense (not a subtraction
|
||||||
|
* like the real-vs-real compare, whose expense figures are ABS'd positive
|
||||||
|
* magnitudes). The net just adds the transfer total on top.
|
||||||
|
*/
|
||||||
|
export function computeResults(rows: BudgetVsActualRow[]): BudgetVsActualResults {
|
||||||
|
const byType = (type: SectionType): Totals =>
|
||||||
|
sumLeaves(rows.filter((r) => r.category_type === type));
|
||||||
|
const income = byType("income");
|
||||||
|
const expense = byType("expense");
|
||||||
|
const transfer = byType("transfer");
|
||||||
|
const resultBefore = add(income, expense);
|
||||||
|
const resultNet = add(resultBefore, transfer);
|
||||||
|
const hasTransfers = rows.some((r) => r.category_type === "transfer" && !r.is_parent);
|
||||||
|
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
||||||
|
}
|
||||||
87
src/components/reports/compareResults.test.ts
Normal file
87
src/components/reports/compareResults.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { CategoryDelta } from "../../shared/types";
|
||||||
|
import { computeResults, sumLeaves } from "./compareResults";
|
||||||
|
|
||||||
|
// Minimal leaf factory. Amounts arrive from COMPARE_DELTA_SQL already in the
|
||||||
|
// per-type sign convention (expenses = positive ABS, income/transfer = signed).
|
||||||
|
function leaf(
|
||||||
|
type: "expense" | "income" | "transfer",
|
||||||
|
mc: number,
|
||||||
|
mp: number,
|
||||||
|
cc = mc,
|
||||||
|
cp = mp,
|
||||||
|
): CategoryDelta {
|
||||||
|
return {
|
||||||
|
categoryId: 1,
|
||||||
|
categoryName: type,
|
||||||
|
categoryColor: "#000",
|
||||||
|
previousAmount: mp,
|
||||||
|
currentAmount: mc,
|
||||||
|
deltaAbs: mc - mp,
|
||||||
|
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
|
||||||
|
cumulativePreviousAmount: cp,
|
||||||
|
cumulativeCurrentAmount: cc,
|
||||||
|
cumulativeDeltaAbs: cc - cp,
|
||||||
|
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
|
||||||
|
category_type: type,
|
||||||
|
is_parent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeResults — income-statement roll-up (Issue #253)", () => {
|
||||||
|
it("resultBefore = revenues − expenses; resultNet adds net transfers", () => {
|
||||||
|
const r = computeResults([
|
||||||
|
leaf("income", 4350, 4500),
|
||||||
|
leaf("expense", 3900, 3700),
|
||||||
|
leaf("transfer", 0, 0),
|
||||||
|
]);
|
||||||
|
// Operating result: 4350 − 3900 = 450 current; 4500 − 3700 = 800 previous.
|
||||||
|
expect(r.resultBefore.monthCurrent).toBe(450);
|
||||||
|
expect(r.resultBefore.monthPrevious).toBe(800);
|
||||||
|
expect(r.resultBefore.monthDelta).toBe(-350);
|
||||||
|
// Balanced transfer (0) → net equals the operating result.
|
||||||
|
expect(r.resultNet.monthCurrent).toBe(450);
|
||||||
|
expect(r.hasTransfers).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unbalanced (one-leg) 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.monthCurrent).toBe(1000);
|
||||||
|
// Net folds the residual transfer in: 1000 + (−200) = 800.
|
||||||
|
expect(r.resultNet.monthCurrent).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.monthCurrent).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.monthCurrent).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.ytdCurrent).toBe(500);
|
||||||
|
expect(r.resultBefore.ytdPrevious).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sumLeaves adds only leaves", () => {
|
||||||
|
const t = sumLeaves([
|
||||||
|
leaf("expense", 100, 50),
|
||||||
|
{ ...leaf("expense", 999, 999), is_parent: true },
|
||||||
|
]);
|
||||||
|
expect(t.monthCurrent).toBe(100);
|
||||||
|
expect(t.monthPrevious).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/components/reports/compareResults.ts
Normal file
90
src/components/reports/compareResults.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { CategoryDelta } from "../../shared/types";
|
||||||
|
|
||||||
|
export type SectionType = "expense" | "income" | "transfer";
|
||||||
|
|
||||||
|
/** Aggregate of the 6 comparable figures across a set of leaf rows. */
|
||||||
|
export interface Totals {
|
||||||
|
monthCurrent: number;
|
||||||
|
monthPrevious: number;
|
||||||
|
monthDelta: number;
|
||||||
|
ytdCurrent: number;
|
||||||
|
ytdPrevious: number;
|
||||||
|
ytdDelta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO: Totals = {
|
||||||
|
monthCurrent: 0,
|
||||||
|
monthPrevious: 0,
|
||||||
|
monthDelta: 0,
|
||||||
|
ytdCurrent: 0,
|
||||||
|
ytdPrevious: 0,
|
||||||
|
ytdDelta: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sum every non-subtotal (leaf) row into a single Totals. */
|
||||||
|
export function sumLeaves(rows: CategoryDelta[]): Totals {
|
||||||
|
return rows
|
||||||
|
.filter((r) => !r.is_parent)
|
||||||
|
.reduce<Totals>(
|
||||||
|
(acc, r) => ({
|
||||||
|
monthCurrent: acc.monthCurrent + r.currentAmount,
|
||||||
|
monthPrevious: acc.monthPrevious + r.previousAmount,
|
||||||
|
monthDelta: acc.monthDelta + r.deltaAbs,
|
||||||
|
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
|
||||||
|
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
|
||||||
|
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
|
||||||
|
}),
|
||||||
|
{ ...ZERO },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pct(delta: number, previous: number): number | null {
|
||||||
|
return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Component-wise a + sign*b across the 6 figures. */
|
||||||
|
function combine(a: Totals, b: Totals, sign: 1 | -1): Totals {
|
||||||
|
return {
|
||||||
|
monthCurrent: a.monthCurrent + sign * b.monthCurrent,
|
||||||
|
monthPrevious: a.monthPrevious + sign * b.monthPrevious,
|
||||||
|
monthDelta: a.monthDelta + sign * b.monthDelta,
|
||||||
|
ytdCurrent: a.ytdCurrent + sign * b.ytdCurrent,
|
||||||
|
ytdPrevious: a.ytdPrevious + sign * b.ytdPrevious,
|
||||||
|
ytdDelta: a.ytdDelta + sign * b.ytdDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareResults {
|
||||||
|
income: Totals;
|
||||||
|
expense: Totals;
|
||||||
|
transfer: Totals;
|
||||||
|
/** Revenues − expenses: the operating result, before transfers. */
|
||||||
|
resultBefore: Totals;
|
||||||
|
/** resultBefore + net transfers: the bottom-line total. */
|
||||||
|
resultNet: Totals;
|
||||||
|
hasTransfers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Income-statement roll-up for the real-vs-real compare (Issue #253).
|
||||||
|
*
|
||||||
|
* Sign convention flowing in from COMPARE_DELTA_SQL: expenses are positive
|
||||||
|
* magnitudes (ABS of outflows), income is a signed SUM (positive), transfers a
|
||||||
|
* signed net (~0 when balanced). So the operating result is a straight
|
||||||
|
* `income − expense`, and the net adds the (usually ~0) transfer total. When a
|
||||||
|
* transfer is unbalanced (a single leg categorised) its residual shows up as
|
||||||
|
* the gap between resultBefore and resultNet.
|
||||||
|
*/
|
||||||
|
export function computeResults(rows: CategoryDelta[]): CompareResults {
|
||||||
|
const byType = (type: SectionType): Totals =>
|
||||||
|
sumLeaves(rows.filter((r) => (r.category_type ?? "expense") === type));
|
||||||
|
const income = byType("income");
|
||||||
|
const expense = byType("expense");
|
||||||
|
const transfer = byType("transfer");
|
||||||
|
const resultBefore = combine(income, expense, -1);
|
||||||
|
const resultNet = combine(resultBefore, transfer, 1);
|
||||||
|
const hasTransfers = rows.some(
|
||||||
|
(r) => (r.category_type ?? "expense") === "transfer" && !r.is_parent,
|
||||||
|
);
|
||||||
|
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
||||||
|
}
|
||||||
195
src/components/reports/overTimeResults.test.ts
Normal file
195
src/components/reports/overTimeResults.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
|
||||||
|
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
||||||
|
|
||||||
|
/** Builds an id-keyed leaf row; `monthly` is index-aligned to the months list. */
|
||||||
|
function leaf(
|
||||||
|
categoryId: number | null,
|
||||||
|
categoryName: string,
|
||||||
|
monthly: number[],
|
||||||
|
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", () => {
|
||||||
|
it("is income-first (income → expense → transfer)", () => {
|
||||||
|
expect(OVER_TIME_TYPE_ORDER).toEqual({ income: 0, expense: 1, transfer: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeOverTimeResults", () => {
|
||||||
|
it("groups leaves into ordered sections with per-month + total subtotals", () => {
|
||||||
|
const data = makeData(
|
||||||
|
["2025-01", "2025-02"],
|
||||||
|
[
|
||||||
|
leaf(2, "Rent", [1000, 1000], "expense"),
|
||||||
|
leaf(4, "Savings", [200, 200], "transfer"),
|
||||||
|
leaf(1, "Salary", [3000, 3000], "income"),
|
||||||
|
leaf(3, "Groceries", [500, 700], "expense"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const r = computeOverTimeResults(data);
|
||||||
|
|
||||||
|
expect(r.months).toEqual(["2025-01", "2025-02"]);
|
||||||
|
// Income-first ordering, regardless of the input row order.
|
||||||
|
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
|
||||||
|
|
||||||
|
const income = r.sections.find((s) => s.type === "income")!;
|
||||||
|
const expense = r.sections.find((s) => s.type === "expense")!;
|
||||||
|
const transfer = r.sections.find((s) => s.type === "transfer")!;
|
||||||
|
|
||||||
|
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
|
||||||
|
expect(income.total).toBe(6000);
|
||||||
|
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Rent", "Groceries"]);
|
||||||
|
expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
|
||||||
|
expect(expense.total).toBe(3200);
|
||||||
|
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
|
||||||
|
expect(transfer.total).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes net (income − expense + transfer) and before-transfers per month", () => {
|
||||||
|
const data = makeData(
|
||||||
|
["2025-01", "2025-02"],
|
||||||
|
[
|
||||||
|
leaf(1, "Salary", [3000, 3000], "income"),
|
||||||
|
leaf(2, "Rent", [1000, 1000], "expense"),
|
||||||
|
leaf(3, "Groceries", [500, 700], "expense"),
|
||||||
|
leaf(4, "Savings", [200, 200], "transfer"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const r = computeOverTimeResults(data);
|
||||||
|
|
||||||
|
expect(r.hasTransfers).toBe(true);
|
||||||
|
// before = income − expense
|
||||||
|
expect(r.beforeTransfers.monthly).toEqual({ "2025-01": 1500, "2025-02": 1300 });
|
||||||
|
expect(r.beforeTransfers.total).toBe(2800);
|
||||||
|
// net = before + transfer
|
||||||
|
expect(r.net.monthly).toEqual({ "2025-01": 1700, "2025-02": 1500 });
|
||||||
|
expect(r.net.total).toBe(3200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses net to before-transfers when there are no transfers", () => {
|
||||||
|
const data = makeData(
|
||||||
|
["2025-01"],
|
||||||
|
[leaf(1, "Salary", [2000], "income"), leaf(2, "Rent", [2500], "expense")],
|
||||||
|
);
|
||||||
|
|
||||||
|
const r = computeOverTimeResults(data);
|
||||||
|
|
||||||
|
expect(r.hasTransfers).toBe(false);
|
||||||
|
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
|
||||||
|
// Deficit: 2000 − 2500 = −500, identical for net and before-transfers.
|
||||||
|
expect(r.beforeTransfers.monthly["2025-01"]).toBe(-500);
|
||||||
|
expect(r.net.monthly["2025-01"]).toBe(-500);
|
||||||
|
expect(r.net.total).toBe(-500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults a leaf with an unknown type to the expense section (defensive)", () => {
|
||||||
|
// The tree normally stamps a valid type on every leaf; the reducer still
|
||||||
|
// guards against a stray value by folding it into expenses.
|
||||||
|
const untyped = {
|
||||||
|
...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 expense = r.sections.find((s) => s.type === "expense")!;
|
||||||
|
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Other"]);
|
||||||
|
expect(expense.total).toBe(300);
|
||||||
|
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 − 300
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty sections and still nets a lone transfer section", () => {
|
||||||
|
const data = makeData(["2025-01"], [leaf(1, "Move", [100], "transfer")]);
|
||||||
|
|
||||||
|
const r = computeOverTimeResults(data);
|
||||||
|
|
||||||
|
expect(r.sections.map((s) => s.type)).toEqual(["transfer"]);
|
||||||
|
expect(r.hasTransfers).toBe(true);
|
||||||
|
expect(r.beforeTransfers.monthly["2025-01"]).toBe(0);
|
||||||
|
expect(r.net.monthly["2025-01"]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps homonym categories of different types apart (Résultat net correct) — Issue #264", () => {
|
||||||
|
// 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(
|
||||||
|
["2025-01"],
|
||||||
|
[leaf(1, "Divers", [1000], "income"), leaf(2, "Divers", [300], "expense")],
|
||||||
|
);
|
||||||
|
|
||||||
|
const r = computeOverTimeResults(data);
|
||||||
|
|
||||||
|
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")!;
|
||||||
|
// 500 + 300 = 800 (the parent's own 800 subtotal is excluded).
|
||||||
|
expect(expense.total).toBe(800);
|
||||||
|
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", () => {
|
||||||
|
const data = makeData(
|
||||||
|
["2025-01", "2025-02"],
|
||||||
|
[leaf(1, "Salary", [1000, 1000], "income"), leaf(2, "Bonus", [0, 500], "income")],
|
||||||
|
);
|
||||||
|
|
||||||
|
const r = computeOverTimeResults(data);
|
||||||
|
|
||||||
|
const income = r.sections.find((s) => s.type === "income")!;
|
||||||
|
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
|
||||||
|
expect(r.net.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
|
||||||
|
expect(r.net.total).toBe(2500);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/components/reports/overTimeResults.ts
Normal file
116
src/components/reports/overTimeResults.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
||||||
|
|
||||||
|
export type OverTimeType = "income" | "expense" | "transfer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Income-statement reading order: revenue first, then expenses, then transfers,
|
||||||
|
* so the "net result" row reads naturally at the bottom (Income − Expenses +
|
||||||
|
* Transfers). Matches the compare report's `COMPARE_TYPE_ORDER`, which is also
|
||||||
|
* income-first since issue #253 (the earlier expense-first note is obsolete).
|
||||||
|
*/
|
||||||
|
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
|
||||||
|
income: 0,
|
||||||
|
expense: 1,
|
||||||
|
transfer: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A month -> value map plus the sum across all its months. */
|
||||||
|
export interface OverTimeSeries {
|
||||||
|
/** month (YYYY-MM) -> value */
|
||||||
|
monthly: Record<string, number>;
|
||||||
|
/** sum across every month */
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rendered section: a type, its member leaf rows (id-keyed, from the tree),
|
||||||
|
* 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;
|
||||||
|
rows: OverTimeRow[];
|
||||||
|
/** month (YYYY-MM) -> section subtotal */
|
||||||
|
monthly: Record<string, number>;
|
||||||
|
/** sum across every month */
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverTimeAnalysis {
|
||||||
|
/** Ordered list of months (mirrors `data.data` order). */
|
||||||
|
months: string[];
|
||||||
|
/** Sections ordered income → expense → transfer; empty types are dropped. */
|
||||||
|
sections: OverTimeSection[];
|
||||||
|
/** True when at least one visible transfer-type category is present. */
|
||||||
|
hasTransfers: boolean;
|
||||||
|
/** Income − Expenses + Transfers, per month (+ grand total). */
|
||||||
|
net: OverTimeSeries;
|
||||||
|
/** Income − Expenses, per month (+ grand total). Only shown when `hasTransfers`. */
|
||||||
|
beforeTransfers: OverTimeSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure reducer that turns the category-over-time report into an "income
|
||||||
|
* statement" shape: per-type sections with per-month subtotals, plus the
|
||||||
|
* net-result rows.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
const typeOf = (row: OverTimeRow): OverTimeType => {
|
||||||
|
const t = row.category_type;
|
||||||
|
return t === "income" || t === "transfer" ? t : "expense";
|
||||||
|
};
|
||||||
|
const byType: Record<OverTimeType, OverTimeRow[]> = { income: [], expense: [], transfer: [] };
|
||||||
|
for (const row of data.tree) {
|
||||||
|
if (!row.is_parent) byType[typeOf(row)].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSection = (type: OverTimeType): OverTimeSection => {
|
||||||
|
const rows = byType[type];
|
||||||
|
const monthly: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
months.forEach((month, i) => {
|
||||||
|
const sum = rows.reduce((s, row) => s + (row.monthly[i] ?? 0), 0);
|
||||||
|
monthly[month] = sum;
|
||||||
|
total += sum;
|
||||||
|
});
|
||||||
|
return { type, rows, monthly, total };
|
||||||
|
};
|
||||||
|
|
||||||
|
const income = buildSection("income");
|
||||||
|
const expense = buildSection("expense");
|
||||||
|
const transfer = buildSection("transfer");
|
||||||
|
|
||||||
|
const hasTransfers = transfer.rows.length > 0;
|
||||||
|
|
||||||
|
const net: OverTimeSeries = { monthly: {}, total: 0 };
|
||||||
|
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
|
||||||
|
for (const month of months) {
|
||||||
|
const before = income.monthly[month] - expense.monthly[month];
|
||||||
|
const netMonth = before + transfer.monthly[month];
|
||||||
|
beforeTransfers.monthly[month] = before;
|
||||||
|
beforeTransfers.total += before;
|
||||||
|
net.monthly[month] = netMonth;
|
||||||
|
net.total += netMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [income, expense, transfer]
|
||||||
|
.filter((s) => s.rows.length > 0)
|
||||||
|
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
|
||||||
|
|
||||||
|
return { months, sections, hasTransfers, net, beforeTransfers };
|
||||||
|
}
|
||||||
188
src/components/reports/overTimeTableModel.test.ts
Normal file
188
src/components/reports/overTimeTableModel.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/components/reports/overTimeTableModel.ts
Normal file
89
src/components/reports/overTimeTableModel.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getAllActiveCategories,
|
getAllActiveCategories,
|
||||||
getBudgetEntriesForYear,
|
getBudgetEntriesForYear,
|
||||||
|
|
@ -11,11 +11,14 @@ 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;
|
||||||
|
|
@ -26,6 +29,7 @@ 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 {
|
||||||
|
|
@ -33,6 +37,7 @@ 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,
|
||||||
|
|
@ -54,6 +59,8 @@ 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:
|
||||||
|
|
@ -61,13 +68,17 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
// Income-statement reading order: revenue first, then expenses, then
|
||||||
|
// transfers (Issue #278) — matches the compare/trend reports' ordering
|
||||||
|
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
|
||||||
|
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
export function useBudget() {
|
export function useBudget() {
|
||||||
|
const { accountIds } = useReportsPeriod();
|
||||||
const [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) => {
|
const refreshData = useCallback(async (year: number, ids: 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 });
|
||||||
|
|
@ -76,7 +87,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),
|
getActualTotalsForYear(year - 1, ids),
|
||||||
getAllTemplates(),
|
getAllTemplates(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -364,8 +375,21 @@ export function useBudget() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshData(state.year);
|
refreshData(state.year, accountIds);
|
||||||
}, [state.year, refreshData]);
|
}, [state.year, accountIds, 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 });
|
||||||
|
|
@ -376,7 +400,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);
|
await refreshData(state.year, accountIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -386,7 +410,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, refreshData]
|
[state.year, accountIds, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const splitEvenly = useCallback(
|
const splitEvenly = useCallback(
|
||||||
|
|
@ -400,7 +424,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);
|
await refreshData(state.year, accountIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -410,7 +434,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, refreshData]
|
[state.year, accountIds, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveTemplate = useCallback(
|
const saveTemplate = useCallback(
|
||||||
|
|
@ -423,7 +447,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);
|
await refreshData(state.year, accountIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -433,7 +457,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.rows, state.year, refreshData]
|
[state.rows, state.year, accountIds, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyTemplate = useCallback(
|
const applyTemplate = useCallback(
|
||||||
|
|
@ -441,7 +465,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);
|
await refreshData(state.year, accountIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -451,7 +475,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, refreshData]
|
[state.year, accountIds, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyTemplateAllMonths = useCallback(
|
const applyTemplateAllMonths = useCallback(
|
||||||
|
|
@ -461,7 +485,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);
|
await refreshData(state.year, accountIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -471,7 +495,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, refreshData]
|
[state.year, accountIds, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteTemplate = useCallback(
|
const deleteTemplate = useCallback(
|
||||||
|
|
@ -479,7 +503,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);
|
await refreshData(state.year, accountIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -489,7 +513,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, refreshData]
|
[state.year, accountIds, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
94
src/hooks/useCollapsibleGroups.ts
Normal file
94
src/hooks/useCollapsibleGroups.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
|
import {
|
||||||
|
previousMonth,
|
||||||
|
defaultReferencePeriod,
|
||||||
|
comparisonMeta,
|
||||||
|
syncReferenceOnPeriodChange,
|
||||||
|
} from "./useCompare";
|
||||||
|
|
||||||
describe("useCompare helpers", () => {
|
describe("useCompare helpers", () => {
|
||||||
describe("previousMonth", () => {
|
describe("previousMonth", () => {
|
||||||
|
|
@ -44,4 +49,40 @@ describe("useCompare helpers", () => {
|
||||||
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
|
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("syncReferenceOnPeriodChange", () => {
|
||||||
|
// The ref is seeded with the initial `to`, so the mount call has to===last.
|
||||||
|
it("no-ops on mount, preserving the previous-month default", () => {
|
||||||
|
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-12-31", 2026, 6);
|
||||||
|
expect(r.next).toBeNull();
|
||||||
|
expect(r.lastSyncedTo).toBe("2026-12-31");
|
||||||
|
});
|
||||||
|
|
||||||
|
// StrictMode double-invokes effects in dev: calling twice with the same `to`
|
||||||
|
// must not sync (a fire-once boolean would wrongly snap to December on run 2).
|
||||||
|
it("is idempotent across a repeated `to` (StrictMode-safe)", () => {
|
||||||
|
const a = syncReferenceOnPeriodChange("2026-12-31", "2026-12-31", 2026, 6);
|
||||||
|
const b = syncReferenceOnPeriodChange(a.lastSyncedTo, "2026-12-31", 2026, 6);
|
||||||
|
expect(a.next).toBeNull();
|
||||||
|
expect(b.next).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs the reference month when `to` changes (user navigation)", () => {
|
||||||
|
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-06-30", 2026, 12);
|
||||||
|
expect(r.next).toEqual({ year: 2026, month: 6 });
|
||||||
|
expect(r.lastSyncedTo).toBe("2026-06-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a changed `to` but does not dispatch when it already matches state", () => {
|
||||||
|
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-06-30", 2026, 6);
|
||||||
|
expect(r.next).toBeNull();
|
||||||
|
expect(r.lastSyncedTo).toBe("2026-06-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unparseable `to`", () => {
|
||||||
|
const r = syncReferenceOnPeriodChange("2026-12-31", "garbage", 2026, 6);
|
||||||
|
expect(r.next).toBeNull();
|
||||||
|
expect(r.lastSyncedTo).toBe("garbage");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type { CategoryDelta } from "../shared/types";
|
import type { CategoryDelta, ImportSource } 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";
|
||||||
|
|
||||||
|
|
@ -13,6 +14,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +25,7 @@ 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 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,6 +63,32 @@ export function comparisonMeta(
|
||||||
return { previousYear: year - 1, previousMonth: month };
|
return { previousYear: year - 1, previousMonth: month };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision for the URL-period → reference-month sync effect.
|
||||||
|
*
|
||||||
|
* Gates on a *change* of `to` (the period's upper bound) rather than a
|
||||||
|
* fire-once flag: seeded with the initial `to`, the mount is a no-op, so the
|
||||||
|
* previous-month default from initialState survives instead of snapping to the
|
||||||
|
* civil-year December that useReportsPeriod yields by default (Issue #253).
|
||||||
|
* Because it keys off a value change, it is idempotent under React StrictMode's
|
||||||
|
* dev double-invoke of effects — called twice with the same `to` it dispatches
|
||||||
|
* nothing the second time (a fire-once boolean would wrongly sync on run #2).
|
||||||
|
*
|
||||||
|
* Returns the `to` to remember plus an optional reference period to dispatch.
|
||||||
|
*/
|
||||||
|
export function syncReferenceOnPeriodChange(
|
||||||
|
lastSyncedTo: string,
|
||||||
|
to: string,
|
||||||
|
currentYear: number,
|
||||||
|
currentMonth: number,
|
||||||
|
): { lastSyncedTo: string; next: { year: number; month: number } | null } {
|
||||||
|
if (to === lastSyncedTo) return { lastSyncedTo, next: null };
|
||||||
|
const [y, m] = to.split("-").map(Number);
|
||||||
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return { lastSyncedTo: to, next: null };
|
||||||
|
if (y === currentYear && m === currentMonth) return { lastSyncedTo: to, next: null };
|
||||||
|
return { lastSyncedTo: to, next: { year: y, month: m } };
|
||||||
|
}
|
||||||
|
|
||||||
const defaultRef = defaultReferencePeriod();
|
const defaultRef = defaultReferencePeriod();
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
mode: "actual",
|
mode: "actual",
|
||||||
|
|
@ -67,6 +96,7 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
@ -83,6 +113,8 @@ 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:
|
||||||
|
|
@ -91,20 +123,26 @@ function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCompare() {
|
export function useCompare() {
|
||||||
const { from, to } = useReportsPeriod();
|
const { from, to, accountIds } = 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 (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
|
async (
|
||||||
|
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)
|
? await getCompareMonthOverMonth(year, month, ids)
|
||||||
: await getCompareYearOverYear(year, month);
|
: await getCompareYearOverYear(year, month, ids);
|
||||||
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) {
|
||||||
|
|
@ -116,18 +154,37 @@ export function useCompare() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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]);
|
}, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]);
|
||||||
|
|
||||||
// When the URL period changes, align the reference month with `to`.
|
// Load the import-source list once, for the FilterPanel's account checkboxes
|
||||||
// The explicit dropdown remains the primary selector — this effect only
|
// (same query as the Trends/Transactions source filters — see useTrends.ts).
|
||||||
// keeps the two in sync when the user navigates via PeriodSelector.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [y, m] = to.split("-").map(Number);
|
(async () => {
|
||||||
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
try {
|
||||||
if (y !== state.year || m !== state.month) {
|
const accounts = await getAllImportSources();
|
||||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
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
|
||||||
|
// via PeriodSelector — but not on mount. The ref is seeded with the initial
|
||||||
|
// `to`, so syncReferenceOnPeriodChange only fires on an actual change of `to`,
|
||||||
|
// leaving the previous-month default from initialState intact (Issue #253) and
|
||||||
|
// staying idempotent under StrictMode's dev double-invoke.
|
||||||
|
const lastSyncedToRef = useRef(to);
|
||||||
|
useEffect(() => {
|
||||||
|
const { lastSyncedTo, next } = syncReferenceOnPeriodChange(
|
||||||
|
lastSyncedToRef.current,
|
||||||
|
to,
|
||||||
|
state.year,
|
||||||
|
state.month,
|
||||||
|
);
|
||||||
|
lastSyncedToRef.current = lastSyncedTo;
|
||||||
|
if (next) dispatch({ type: "SET_REFERENCE_PERIOD", payload: next });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [to]);
|
}, [to]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,63 @@
|
||||||
|
// useDashboard — scoped useReducer hook backing DashboardPage (the `/` home page).
|
||||||
|
//
|
||||||
|
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
|
||||||
|
// row, top movers and budget adherence are the exact Cartes widgets, sourced
|
||||||
|
// from `getCartesSnapshot` against a (year, month) reference period. The
|
||||||
|
// Dashboard keeps its OWN two temporal axes, neither of which is
|
||||||
|
// `useReportsPeriod` (M1/I4 deliberately left the Dashboard untouched):
|
||||||
|
// - `period` / `customDateFrom` / `customDateTo` — a flexible date range,
|
||||||
|
// feeding the two range-based transactional widgets (top-expenses bar
|
||||||
|
// chart, category-over-time trend).
|
||||||
|
// - `referenceYear` / `referenceMonth` — a single reference month, feeding
|
||||||
|
// the Cartes snapshot (KPIs + deltas, top movers, budget adherence, the
|
||||||
|
// income-vs-expenses overlay chart). Defaults to the previous complete
|
||||||
|
// month, like Cartes/Compare (`defaultReferencePeriod`).
|
||||||
|
//
|
||||||
|
// The account (import-source) filter (`accountIds`) is Dashboard-local state
|
||||||
|
// (not routed through `useReportsPeriod`), but applies to every transactional
|
||||||
|
// widget above — both temporal axes. It does NOT scope the net-worth tile
|
||||||
|
// (see below): `balance_accounts` is a disjoint concept from `import_sources`.
|
||||||
|
//
|
||||||
|
// The net-worth tile is fetched independently, once, on mount — it is not a
|
||||||
|
// transactional figure, has no relationship to `period`/`referenceMonth`, and
|
||||||
|
// must not be scoped by `accountIds`. Keeping it in its own effect makes that
|
||||||
|
// decoupling explicit in the code, not just in the UI copy.
|
||||||
|
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type {
|
import type {
|
||||||
DashboardPeriod,
|
DashboardPeriod,
|
||||||
DashboardSummary,
|
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
BudgetVsActualRow,
|
CartesSnapshot,
|
||||||
|
ImportSource,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getDashboardSummary,
|
|
||||||
getExpensesByCategory,
|
getExpensesByCategory,
|
||||||
|
deriveNetWorthTile,
|
||||||
|
type NetWorthTileData,
|
||||||
} from "../services/dashboardService";
|
} from "../services/dashboardService";
|
||||||
import { getCategoryOverTime } from "../services/reportService";
|
import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService";
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
import { getAllImportSources } from "../services/transactionService";
|
||||||
|
import {
|
||||||
|
getSnapshotTotalsByDate,
|
||||||
|
listSnapshots,
|
||||||
|
listBalanceAccounts,
|
||||||
|
} from "../services/balance.service";
|
||||||
import { computeDateRange } from "../utils/dateRange";
|
import { computeDateRange } from "../utils/dateRange";
|
||||||
|
import { defaultReferencePeriod } from "../utils/referencePeriod";
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
summary: DashboardSummary;
|
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
budgetVsActual: BudgetVsActualRow[];
|
cartesSnapshot: CartesSnapshot | null;
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
budgetYear: number;
|
|
||||||
budgetMonth: number;
|
|
||||||
customDateFrom: string;
|
customDateFrom: string;
|
||||||
customDateTo: string;
|
customDateTo: string;
|
||||||
|
referenceYear: number;
|
||||||
|
referenceMonth: number;
|
||||||
|
accountIds: number[];
|
||||||
|
accounts: ImportSource[];
|
||||||
|
netWorth: NetWorthTileData;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -34,30 +68,46 @@ type DashboardAction =
|
||||||
| {
|
| {
|
||||||
type: "SET_DATA";
|
type: "SET_DATA";
|
||||||
payload: {
|
payload: {
|
||||||
summary: DashboardSummary;
|
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
budgetVsActual: BudgetVsActualRow[];
|
cartesSnapshot: CartesSnapshot;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
||||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||||
|
| { type: "SET_ACCOUNT_IDS"; payload: number[] }
|
||||||
|
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||||
|
| { type: "SET_NET_WORTH"; payload: NetWorthTileData };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
const yearStartStr = `${now.getFullYear()}-01-01`;
|
const yearStartStr = `${now.getFullYear()}-01-01`;
|
||||||
|
const defaultRef = defaultReferencePeriod();
|
||||||
|
|
||||||
|
const EMPTY_CATEGORY_OVER_TIME: CategoryOverTimeData = {
|
||||||
|
categories: [],
|
||||||
|
data: [],
|
||||||
|
colors: {},
|
||||||
|
categoryIds: {},
|
||||||
|
types: {},
|
||||||
|
tree: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_NET_WORTH: NetWorthTileData = { visible: false, total: null, asOfDate: null };
|
||||||
|
|
||||||
const initialState: DashboardState = {
|
const initialState: DashboardState = {
|
||||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
|
||||||
categoryBreakdown: [],
|
categoryBreakdown: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: EMPTY_CATEGORY_OVER_TIME,
|
||||||
budgetVsActual: [],
|
cartesSnapshot: null,
|
||||||
period: "year",
|
period: "year",
|
||||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
|
||||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
|
||||||
customDateFrom: yearStartStr,
|
customDateFrom: yearStartStr,
|
||||||
customDateTo: todayStr,
|
customDateTo: todayStr,
|
||||||
|
referenceYear: defaultRef.year,
|
||||||
|
referenceMonth: defaultRef.month,
|
||||||
|
accountIds: [],
|
||||||
|
accounts: [],
|
||||||
|
netWorth: EMPTY_NET_WORTH,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -71,18 +121,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
||||||
case "SET_DATA":
|
case "SET_DATA":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
summary: action.payload.summary,
|
|
||||||
categoryBreakdown: action.payload.categoryBreakdown,
|
categoryBreakdown: action.payload.categoryBreakdown,
|
||||||
categoryOverTime: action.payload.categoryOverTime,
|
categoryOverTime: action.payload.categoryOverTime,
|
||||||
budgetVsActual: action.payload.budgetVsActual,
|
cartesSnapshot: action.payload.cartesSnapshot,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
case "SET_PERIOD":
|
case "SET_PERIOD":
|
||||||
return { ...state, period: action.payload };
|
return { ...state, period: action.payload };
|
||||||
case "SET_BUDGET_MONTH":
|
|
||||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
|
||||||
case "SET_CUSTOM_DATES":
|
case "SET_CUSTOM_DATES":
|
||||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
return {
|
||||||
|
...state,
|
||||||
|
period: "custom" as DashboardPeriod,
|
||||||
|
customDateFrom: action.payload.dateFrom,
|
||||||
|
customDateTo: action.payload.dateTo,
|
||||||
|
};
|
||||||
|
case "SET_REFERENCE_PERIOD":
|
||||||
|
return { ...state, referenceYear: action.payload.year, referenceMonth: action.payload.month };
|
||||||
|
case "SET_ACCOUNT_IDS":
|
||||||
|
return { ...state, accountIds: action.payload };
|
||||||
|
case "SET_ACCOUNTS":
|
||||||
|
return { ...state, accounts: action.payload };
|
||||||
|
case "SET_NET_WORTH":
|
||||||
|
return { ...state, netWorth: action.payload };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
@ -96,8 +156,9 @@ export function useDashboard() {
|
||||||
period: DashboardPeriod,
|
period: DashboardPeriod,
|
||||||
customFrom: string | undefined,
|
customFrom: string | undefined,
|
||||||
customTo: string | undefined,
|
customTo: string | undefined,
|
||||||
bYear: number,
|
refYear: number,
|
||||||
bMonth: number,
|
refMonth: number,
|
||||||
|
accountIds: number[],
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
|
@ -105,15 +166,17 @@ export function useDashboard() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([
|
||||||
getDashboardSummary(dateFrom, dateTo),
|
getExpensesByCategory(dateFrom, dateTo, accountIds),
|
||||||
getExpensesByCategory(dateFrom, dateTo),
|
// typeFilter "expense" (Issue #279): this chart is titled "expenses
|
||||||
getCategoryOverTime(dateFrom, dateTo),
|
// over time" but previously aggregated every category type — a
|
||||||
getBudgetVsActualData(bYear, bMonth),
|
// revenue category could silently show up in an "expenses" trend.
|
||||||
|
getCategoryOverTime(dateFrom, dateTo, undefined, accountIds, "expense"),
|
||||||
|
getCartesSnapshot(refYear, refMonth, "month", accountIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
@ -124,8 +187,59 @@ export function useDashboard() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
fetchData(
|
||||||
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
state.period,
|
||||||
|
state.customDateFrom,
|
||||||
|
state.customDateTo,
|
||||||
|
state.referenceYear,
|
||||||
|
state.referenceMonth,
|
||||||
|
state.accountIds,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
state.period,
|
||||||
|
state.customDateFrom,
|
||||||
|
state.customDateTo,
|
||||||
|
state.referenceYear,
|
||||||
|
state.referenceMonth,
|
||||||
|
state.accountIds,
|
||||||
|
fetchData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// One-time: import sources for the FilterPanel's account checkboxes (same
|
||||||
|
// query as Trends/Compare/Budget — see useTrends.ts).
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const accounts = await getAllImportSources();
|
||||||
|
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// One-time: net-worth tile (Bilan). Independent of `period`/`referenceMonth`
|
||||||
|
// and NOT scoped by `accountIds` — a different "account" concept
|
||||||
|
// (balance_accounts vs import_sources) and a different metric (net worth vs
|
||||||
|
// transaction P&L). A failure here only hides the tile (best-effort); it
|
||||||
|
// never surfaces as a page-wide error for what is a secondary widget.
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [accounts, snapshots, totals] = await Promise.all([
|
||||||
|
listBalanceAccounts(),
|
||||||
|
listSnapshots(),
|
||||||
|
getSnapshotTotalsByDate(),
|
||||||
|
]);
|
||||||
|
dispatch({
|
||||||
|
type: "SET_NET_WORTH",
|
||||||
|
payload: deriveNetWorthTile(accounts.length, snapshots.length > 0, totals),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
dispatch({ type: "SET_NET_WORTH", payload: EMPTY_NET_WORTH });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
|
|
@ -135,9 +249,13 @@ export function useDashboard() {
|
||||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setBudgetMonth = useCallback((year: number, month: number) => {
|
const setReferencePeriod = useCallback((year: number, month: number) => {
|
||||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { state, setPeriod, setCustomDates, setBudgetMonth };
|
const setAccountIds = useCallback((accountIds: number[]) => {
|
||||||
|
dispatch({ type: "SET_ACCOUNT_IDS", payload: accountIds });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setPeriod, setCustomDates, setReferencePeriod, setAccountIds };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { resolveReportsPeriod } from "./useReportsPeriod";
|
import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } 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,3 +51,76 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,43 @@ 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.
|
||||||
|
|
@ -57,15 +94,19 @@ 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 via the URL query string so it is
|
* Reads/writes the active reporting period — and, additively, the account
|
||||||
* bookmarkable and shared across the four report sub-routes.
|
* (import source) filter — via the URL query string so both are bookmarkable
|
||||||
|
* and shared across the report sub-routes.
|
||||||
*
|
*
|
||||||
* Defaults to the current civil year (Jan 1 → Dec 31).
|
* Defaults to the current civil year (Jan 1 → Dec 31) with no account filter
|
||||||
|
* (`accountIds: []`, meaning all accounts).
|
||||||
*/
|
*/
|
||||||
export function useReportsPeriod(): UseReportsPeriodResult {
|
export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -73,12 +114,15 @@ 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(
|
||||||
|
|
@ -115,5 +159,24 @@ export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
[setSearchParams],
|
[setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { from, to, period, setPeriod, setCustomDates };
|
const setAccountIds = useCallback(
|
||||||
|
(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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,12 @@ import {
|
||||||
initialState,
|
initialState,
|
||||||
makeEmptyHolding,
|
makeEmptyHolding,
|
||||||
holdingsFromServiceHoldings,
|
holdingsFromServiceHoldings,
|
||||||
|
holdingsFromCsvRows,
|
||||||
buildSimpleLines,
|
buildSimpleLines,
|
||||||
buildDetailedLines,
|
buildDetailedLines,
|
||||||
type HoldingDraft,
|
type HoldingDraft,
|
||||||
} from "./useSnapshotEditor";
|
} from "./useSnapshotEditor";
|
||||||
|
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
|
||||||
import { BalanceServiceError } from "../services/balance.service";
|
import { BalanceServiceError } from "../services/balance.service";
|
||||||
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";
|
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";
|
||||||
|
|
||||||
|
|
@ -291,6 +293,222 @@ describe("buildDetailedLines — holdings save path (#213)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("holdingsFromCsvRows — CSV rows → drafts (#245)", () => {
|
||||||
|
const withPrice: HoldingColumnMapping = {
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 2,
|
||||||
|
book_cost: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("maps rows to drafts, normalizing the symbol and parsing FR numbers", () => {
|
||||||
|
const drafts = holdingsFromCsvRows(
|
||||||
|
[
|
||||||
|
["aapl", "10", "150,25", "1 200,00"],
|
||||||
|
["msft", "5", "300.50", "1400"],
|
||||||
|
],
|
||||||
|
withPrice
|
||||||
|
);
|
||||||
|
expect(drafts).toHaveLength(2);
|
||||||
|
expect(drafts[0].symbol).toBe("AAPL"); // normalized UPPER/TRIM
|
||||||
|
expect(drafts[0].quantity).toBe("10");
|
||||||
|
expect(drafts[0].unit_price).toBe("150.25"); // FR comma decimal
|
||||||
|
expect(drafts[0].book_cost).toBe("1200"); // FR thousands space stripped
|
||||||
|
expect(drafts[1].symbol).toBe("MSFT");
|
||||||
|
expect(drafts[1].unit_price).toBe("300.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unit_price empty when there is no price column (flexible price)", () => {
|
||||||
|
const noPrice: HoldingColumnMapping = {
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: null,
|
||||||
|
book_cost: null,
|
||||||
|
};
|
||||||
|
const [d] = holdingsFromCsvRows([["AAPL", "10"]], noPrice);
|
||||||
|
expect(d.symbol).toBe("AAPL");
|
||||||
|
expect(d.quantity).toBe("10");
|
||||||
|
expect(d.unit_price).toBe(""); // → validated at save (fetch/type later)
|
||||||
|
expect(d.book_cost).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges duplicate symbols: SUM quantity + book_cost, keep first price", () => {
|
||||||
|
const drafts = holdingsFromCsvRows(
|
||||||
|
[
|
||||||
|
["AAPL", "6", "150.00", "700"],
|
||||||
|
["MSFT", "5", "300.00", "1400"],
|
||||||
|
["AAPL", "4", "151.00", "500"], // second AAPL lot
|
||||||
|
],
|
||||||
|
withPrice
|
||||||
|
);
|
||||||
|
// Two distinct securities → two drafts, no UNIQUE(symbol) violation.
|
||||||
|
expect(drafts).toHaveLength(2);
|
||||||
|
const aapl = drafts.find((d) => d.symbol === "AAPL")!;
|
||||||
|
expect(aapl.quantity).toBe("10"); // 6 + 4
|
||||||
|
expect(aapl.book_cost).toBe("1200"); // 700 + 500
|
||||||
|
expect(aapl.unit_price).toBe("150"); // FIRST non-empty price wins
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows with a blank symbol and defaults asset_type", () => {
|
||||||
|
const drafts = holdingsFromCsvRows(
|
||||||
|
[
|
||||||
|
["", "10", "1", "1"],
|
||||||
|
[" ", "10", "1", "1"],
|
||||||
|
["BTC", "0.5", "1000", "400"],
|
||||||
|
],
|
||||||
|
withPrice,
|
||||||
|
{ defaultAssetType: "crypto" }
|
||||||
|
);
|
||||||
|
expect(drafts).toHaveLength(1);
|
||||||
|
expect(drafts[0].symbol).toBe("BTC");
|
||||||
|
expect(drafts[0].asset_type).toBe("crypto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IMPORT_HOLDINGS reducer — bulk merge into a basket (#245)", () => {
|
||||||
|
const mapping: HoldingColumnMapping = {
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 2,
|
||||||
|
book_cost: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("appends imported drafts into an empty basket", () => {
|
||||||
|
const drafts = holdingsFromCsvRows(
|
||||||
|
[
|
||||||
|
["AAPL", "10", "150", "1200"],
|
||||||
|
["MSFT", "5", "300", "1400"],
|
||||||
|
],
|
||||||
|
mapping
|
||||||
|
);
|
||||||
|
const s = reducer(base, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: drafts },
|
||||||
|
});
|
||||||
|
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "MSFT"]);
|
||||||
|
expect(s.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing symbol in place (keeps its rowId) and appends new ones", () => {
|
||||||
|
const first = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
|
||||||
|
let s = reducer(base, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: first },
|
||||||
|
});
|
||||||
|
const aaplRowId = s.holdings[5][0].rowId;
|
||||||
|
|
||||||
|
const second = holdingsFromCsvRows(
|
||||||
|
[
|
||||||
|
["AAPL", "12", "160", "1300"],
|
||||||
|
["GOOG", "2", "140", "260"],
|
||||||
|
],
|
||||||
|
mapping
|
||||||
|
);
|
||||||
|
s = reducer(s, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: second },
|
||||||
|
});
|
||||||
|
// No duplicate AAPL row — merged in place with a stable rowId.
|
||||||
|
expect(s.holdings[5].filter((h) => h.symbol === "AAPL")).toHaveLength(1);
|
||||||
|
const aapl = s.holdings[5].find((h) => h.symbol === "AAPL")!;
|
||||||
|
expect(aapl.rowId).toBe(aaplRowId);
|
||||||
|
expect(aapl.quantity).toBe("12"); // import wins for quantity
|
||||||
|
expect(aapl.unit_price).toBe("160");
|
||||||
|
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "GOOG"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an import WITHOUT price does not wipe a previously-set price", () => {
|
||||||
|
const priced = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
|
||||||
|
let s = reducer(base, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: priced },
|
||||||
|
});
|
||||||
|
const noPriceMapping: HoldingColumnMapping = {
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: null,
|
||||||
|
book_cost: null,
|
||||||
|
};
|
||||||
|
const repriced = holdingsFromCsvRows([["AAPL", "20"]], noPriceMapping);
|
||||||
|
s = reducer(s, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: repriced },
|
||||||
|
});
|
||||||
|
const aapl = s.holdings[5][0];
|
||||||
|
expect(aapl.quantity).toBe("20"); // qty updated
|
||||||
|
expect(aapl.unit_price).toBe("150"); // price preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces no duplicate symbols when the batch itself repeats a symbol", () => {
|
||||||
|
// holdingsFromCsvRows already merges within the batch; the reducer is a
|
||||||
|
// second guard. Even if fed pre-merged drafts, the basket stays unique.
|
||||||
|
const drafts = holdingsFromCsvRows(
|
||||||
|
[
|
||||||
|
["AAPL", "3", "150", "450"],
|
||||||
|
["AAPL", "7", "150", "1050"],
|
||||||
|
],
|
||||||
|
mapping
|
||||||
|
);
|
||||||
|
const s = reducer(base, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: drafts },
|
||||||
|
});
|
||||||
|
expect(s.holdings[5]).toHaveLength(1);
|
||||||
|
expect(s.holdings[5][0].quantity).toBe("10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IMPORT_HOLDINGS → buildDetailedLines end-to-end (#245)", () => {
|
||||||
|
const mapping: HoldingColumnMapping = {
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 2,
|
||||||
|
book_cost: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("imported priced drafts flow through buildDetailedLines to holdings", () => {
|
||||||
|
const drafts = holdingsFromCsvRows(
|
||||||
|
[
|
||||||
|
["AAPL", "10", "150.25", "1200"],
|
||||||
|
["MSFT", "5", "300.50", "1400"],
|
||||||
|
],
|
||||||
|
mapping
|
||||||
|
);
|
||||||
|
const s = reducer(base, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: drafts },
|
||||||
|
});
|
||||||
|
const [line] = buildDetailedLines(s.holdings, new Set([5]));
|
||||||
|
expect(line.holdings).toHaveLength(2);
|
||||||
|
expect(line.holdings![0].symbol).toBe("AAPL");
|
||||||
|
expect(line.holdings![0].unit_price).toBe(150.25);
|
||||||
|
expect(line.holdings![0].book_cost).toBe(1200);
|
||||||
|
// 10 * 150.25 + 5 * 300.50 = 1502.50 + 1502.50 = 3005.
|
||||||
|
expect(line.value).toBe(3005);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imported drafts WITHOUT price fail save validation until fetched (GOTCHA)", () => {
|
||||||
|
const noPriceMapping: HoldingColumnMapping = {
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: null,
|
||||||
|
book_cost: null,
|
||||||
|
};
|
||||||
|
const drafts = holdingsFromCsvRows([["AAPL", "10"]], noPriceMapping);
|
||||||
|
const s = reducer(base, {
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId: 5, holdings: drafts },
|
||||||
|
});
|
||||||
|
let code: string | null = null;
|
||||||
|
try {
|
||||||
|
buildDetailedLines(s.holdings, new Set([5]));
|
||||||
|
} catch (e) {
|
||||||
|
code = (e as BalanceServiceError).code;
|
||||||
|
}
|
||||||
|
expect(code).toBe("snapshot_priced_unit_price_required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => {
|
describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => {
|
||||||
it("routes a detailed account through holdings even if its category is simple", () => {
|
it("routes a detailed account through holdings even if its category is simple", () => {
|
||||||
// Regression target: the account's OWN kind decides the path. Account 5 is
|
// Regression target: the account's OWN kind decides the path. Account 5 is
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,13 @@ import {
|
||||||
getPreviousSnapshot,
|
getPreviousSnapshot,
|
||||||
getHoldingsForLatestSnapshot,
|
getHoldingsForLatestSnapshot,
|
||||||
listHoldingsBySnapshotLine,
|
listHoldingsBySnapshotLine,
|
||||||
|
normalizeSecuritySymbol,
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
type SnapshotLineInput,
|
type SnapshotLineInput,
|
||||||
type SnapshotHoldingInput,
|
type SnapshotHoldingInput,
|
||||||
} from "../services/balance.service";
|
} from "../services/balance.service";
|
||||||
|
import { parseFrenchAmount } from "../utils/amountParser";
|
||||||
|
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
|
||||||
|
|
||||||
export type SnapshotEditorMode = "new" | "edit";
|
export type SnapshotEditorMode = "new" | "edit";
|
||||||
|
|
||||||
|
|
@ -152,6 +155,77 @@ export function holdingsFromServiceHoldings(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map parsed CSV data rows into holding drafts for a detailed account (Issue
|
||||||
|
* #245 — CSV import). `rows` are DATA rows only (no header); `mapping` gives the
|
||||||
|
* column indices from `analyzeHoldingsCsv`. Behavior:
|
||||||
|
* - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so
|
||||||
|
* an imported title collapses onto the same `balance_securities` row.
|
||||||
|
* - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`).
|
||||||
|
* - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no
|
||||||
|
* price/cost column) the field stays empty; the user fetches/types it later.
|
||||||
|
* - Duplicate symbols WITHIN the CSV are merged into one draft to respect the
|
||||||
|
* UNIQUE(snapshot_line_id, security_id) constraint: quantities and book_costs
|
||||||
|
* are SUMMED (multiple lots of the same title) and the FIRST non-empty price
|
||||||
|
* is kept (the current market price is shared across lots).
|
||||||
|
* Rows with a blank symbol are skipped. Exported for unit tests.
|
||||||
|
*/
|
||||||
|
export function holdingsFromCsvRows(
|
||||||
|
rows: string[][],
|
||||||
|
mapping: HoldingColumnMapping,
|
||||||
|
opts: { defaultAssetType?: BalanceAssetType } = {}
|
||||||
|
): HoldingDraft[] {
|
||||||
|
const defaultAssetType = opts.defaultAssetType ?? "stock";
|
||||||
|
const order: string[] = [];
|
||||||
|
const bySymbol = new Map<
|
||||||
|
string,
|
||||||
|
{ symbol: string; qty: number; book: number | null; price: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const rawSymbol = (row[mapping.symbol] ?? "").trim();
|
||||||
|
if (!rawSymbol) continue;
|
||||||
|
const symbol = normalizeSecuritySymbol(rawSymbol);
|
||||||
|
if (!symbol) continue;
|
||||||
|
|
||||||
|
const qtyParsed = parseFrenchAmount((row[mapping.quantity] ?? "").trim());
|
||||||
|
const qty = isNaN(qtyParsed) ? 0 : qtyParsed;
|
||||||
|
|
||||||
|
let price = "";
|
||||||
|
if (mapping.unit_price !== null) {
|
||||||
|
const p = parseFrenchAmount((row[mapping.unit_price] ?? "").trim());
|
||||||
|
if (!isNaN(p)) price = String(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
let book: number | null = null;
|
||||||
|
if (mapping.book_cost !== null) {
|
||||||
|
const b = parseFrenchAmount((row[mapping.book_cost] ?? "").trim());
|
||||||
|
if (!isNaN(b)) book = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = bySymbol.get(symbol);
|
||||||
|
if (existing) {
|
||||||
|
existing.qty += qty;
|
||||||
|
if (book !== null) existing.book = (existing.book ?? 0) + book;
|
||||||
|
if (!existing.price && price) existing.price = price; // first non-empty
|
||||||
|
} else {
|
||||||
|
order.push(symbol);
|
||||||
|
bySymbol.set(symbol, { symbol, qty, book, price });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return order.map((sym) => {
|
||||||
|
const a = bySymbol.get(sym)!;
|
||||||
|
return {
|
||||||
|
...makeEmptyHolding(defaultAssetType),
|
||||||
|
symbol: a.symbol,
|
||||||
|
quantity: String(a.qty),
|
||||||
|
unit_price: a.price,
|
||||||
|
book_cost: a.book !== null ? String(a.book) : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
mode: SnapshotEditorMode;
|
mode: SnapshotEditorMode;
|
||||||
/** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
|
/** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
|
||||||
|
|
@ -212,6 +286,14 @@ type Action =
|
||||||
type: "ADD_HOLDING";
|
type: "ADD_HOLDING";
|
||||||
payload: { accountId: number; holding: HoldingDraft };
|
payload: { accountId: number; holding: HoldingDraft };
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
// Bulk-merge a batch of imported drafts into a detailed account's basket
|
||||||
|
// (Issue #245 — CSV import). Merges by normalized symbol so the batch
|
||||||
|
// never introduces a duplicate that would violate
|
||||||
|
// UNIQUE(snapshot_line_id, security_id) at save.
|
||||||
|
type: "IMPORT_HOLDINGS";
|
||||||
|
payload: { accountId: number; holdings: HoldingDraft[] };
|
||||||
|
}
|
||||||
| { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
|
| { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
|
||||||
| {
|
| {
|
||||||
type: "SET_HOLDING_FIELD";
|
type: "SET_HOLDING_FIELD";
|
||||||
|
|
@ -325,6 +407,56 @@ export function reducer(state: State, action: Action): State {
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "IMPORT_HOLDINGS": {
|
||||||
|
// Merge the imported batch into the existing basket by NORMALIZED symbol.
|
||||||
|
// A symbol already present is UPDATED in place (keeping its rowId for a
|
||||||
|
// stable React key); a new symbol is appended. This guarantees the basket
|
||||||
|
// never holds two rows for the same security (UNIQUE constraint at save).
|
||||||
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||||
|
const result = existing.slice();
|
||||||
|
const indexBySymbol = new Map<string, number>();
|
||||||
|
result.forEach((h, i) => {
|
||||||
|
const key = normalizeSecuritySymbol(h.symbol);
|
||||||
|
if (key) indexBySymbol.set(key, i);
|
||||||
|
});
|
||||||
|
for (const draft of action.payload.holdings) {
|
||||||
|
const key = normalizeSecuritySymbol(draft.symbol);
|
||||||
|
if (!key) continue; // imported rows always carry a symbol; guard anyway
|
||||||
|
const idx = indexBySymbol.get(key);
|
||||||
|
if (idx !== undefined) {
|
||||||
|
const prev = result[idx];
|
||||||
|
result[idx] = {
|
||||||
|
...prev,
|
||||||
|
symbol: draft.symbol,
|
||||||
|
// Keep the existing asset_type: the CSV carries none (the draft only
|
||||||
|
// has the account's default class), while the current row's class
|
||||||
|
// was set deliberately via the picker or a prior entry.
|
||||||
|
security_name: draft.security_name || prev.security_name,
|
||||||
|
quantity: draft.quantity,
|
||||||
|
// Import wins for price/book_cost ONLY when it carries a value, so an
|
||||||
|
// import-without-price never wipes a previously fetched/typed price.
|
||||||
|
unit_price: draft.unit_price !== "" ? draft.unit_price : prev.unit_price,
|
||||||
|
book_cost: draft.book_cost !== "" ? draft.book_cost : prev.book_cost,
|
||||||
|
// A fresh imported price is a manual value → drop stale attribution.
|
||||||
|
price_source:
|
||||||
|
draft.unit_price !== "" ? null : prev.price_source,
|
||||||
|
price_fetched_at:
|
||||||
|
draft.unit_price !== "" ? null : prev.price_fetched_at,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
indexBySymbol.set(key, result.length);
|
||||||
|
result.push(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
holdings: {
|
||||||
|
...state.holdings,
|
||||||
|
[action.payload.accountId]: result,
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
case "REMOVE_HOLDING": {
|
case "REMOVE_HOLDING": {
|
||||||
const existing = state.holdings[action.payload.accountId] ?? [];
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||||
return {
|
return {
|
||||||
|
|
@ -665,6 +797,35 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a batch of holdings from parsed CSV rows into a detailed account
|
||||||
|
* (Issue #245). Builds drafts via `holdingsFromCsvRows` (FR numbers, optional
|
||||||
|
* price, duplicate-symbol merge) then dispatches IMPORT_HOLDINGS, which merges
|
||||||
|
* them into the account's basket by symbol. New securities default to the
|
||||||
|
* account's category asset class (else 'stock').
|
||||||
|
*/
|
||||||
|
const importHoldings = useCallback(
|
||||||
|
(accountId: number, rows: string[][], mapping: HoldingColumnMapping) => {
|
||||||
|
const acc = state.accounts.find((a) => a.id === accountId);
|
||||||
|
const drafts = holdingsFromCsvRows(rows, mapping, {
|
||||||
|
defaultAssetType: acc?.category_asset_type ?? "stock",
|
||||||
|
});
|
||||||
|
if (drafts.length === 0) {
|
||||||
|
logInfo("Balance: CSV import produced no holdings (no valid rows)");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: "IMPORT_HOLDINGS",
|
||||||
|
payload: { accountId, holdings: drafts },
|
||||||
|
});
|
||||||
|
logInfo(
|
||||||
|
`Balance: imported ${drafts.length} holding(s) from CSV into account ${accountId}`
|
||||||
|
);
|
||||||
|
return drafts.length;
|
||||||
|
},
|
||||||
|
[state.accounts]
|
||||||
|
);
|
||||||
|
|
||||||
const removeHolding = useCallback((accountId: number, rowId: string) => {
|
const removeHolding = useCallback((accountId: number, rowId: string) => {
|
||||||
dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
|
dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -843,6 +1004,7 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
setDate,
|
setDate,
|
||||||
setLineValue,
|
setLineValue,
|
||||||
addHolding,
|
addHolding,
|
||||||
|
importHoldings,
|
||||||
removeHolding,
|
removeHolding,
|
||||||
setHoldingField,
|
setHoldingField,
|
||||||
setHoldingSecurity,
|
setHoldingSecurity,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||||
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
|
import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } 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";
|
||||||
|
|
@ -9,6 +10,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -18,12 +20,14 @@ 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: "global",
|
subView: "byCategory",
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
||||||
|
accounts: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -38,6 +42,8 @@ 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:
|
||||||
|
|
@ -46,32 +52,48 @@ function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrends() {
|
export function useTrends() {
|
||||||
const { from, to } = useReportsPeriod();
|
const { from, to, accountIds } = useReportsPeriod();
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
|
const fetch = useCallback(
|
||||||
const id = ++fetchIdRef.current;
|
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
const id = ++fetchIdRef.current;
|
||||||
try {
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
if (subView === "global") {
|
try {
|
||||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
if (subView === "global") {
|
||||||
|
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_TRENDS", payload: data });
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
} 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(() => {
|
useEffect(() => {
|
||||||
fetch(state.subView, from, to);
|
fetch(state.subView, from, to, accountIds);
|
||||||
}, [fetch, state.subView, from, to]);
|
}, [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) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setSubView = useCallback((sv: TrendsSubView) => {
|
const setSubView = useCallback((sv: TrendsSubView) => {
|
||||||
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"balance": "Balance",
|
"kpiSectionTitle": "Key indicators",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
"net": "Net",
|
"net": "Net",
|
||||||
|
|
@ -50,10 +50,11 @@
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to use the Dashboard",
|
"title": "How to use the Dashboard",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Use the period selector (top right) to view different time ranges",
|
"The filter bar at the top picks the date range and which import sources to include",
|
||||||
"Summary cards show your balance, income, and expenses for the selected period",
|
"The key indicators (income, expenses, net, savings rate) cover the chosen reference month, with their change vs the previous month and vs last year",
|
||||||
"The pie chart breaks down your expenses by category",
|
"The \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source",
|
||||||
"Recent transactions are listed at the bottom"
|
"Rising/falling categories and budget adherence summarize the reference month's changes",
|
||||||
|
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -379,6 +380,10 @@
|
||||||
"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"
|
||||||
|
|
@ -388,15 +393,18 @@
|
||||||
"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",
|
||||||
|
|
@ -427,7 +435,16 @@
|
||||||
"referenceMonth": "Reference month",
|
"referenceMonth": "Reference month",
|
||||||
"currentAmount": "Current",
|
"currentAmount": "Current",
|
||||||
"previousAmount": "Previous",
|
"previousAmount": "Previous",
|
||||||
"totalRow": "Total"
|
"sections": {
|
||||||
|
"expenses": "Expenses",
|
||||||
|
"income": "Income",
|
||||||
|
"transfers": "Transfers"
|
||||||
|
},
|
||||||
|
"totalExpenses": "Total Expenses",
|
||||||
|
"totalIncome": "Total Income",
|
||||||
|
"totalTransfers": "Total Transfers",
|
||||||
|
"resultBeforeTransfers": "Result before transfers",
|
||||||
|
"resultNet": "Net result"
|
||||||
},
|
},
|
||||||
"cartes": {
|
"cartes": {
|
||||||
"kpiSectionAria": "Key indicators for the reference month",
|
"kpiSectionAria": "Key indicators for the reference month",
|
||||||
|
|
@ -1435,6 +1452,7 @@
|
||||||
"loadError": "Failed to load profile data: {{error}}",
|
"loadError": "Failed to load profile data: {{error}}",
|
||||||
"needsReview": "Needs review",
|
"needsReview": "Needs review",
|
||||||
"chooseTarget": "Choose a target...",
|
"chooseTarget": "Choose a target...",
|
||||||
|
"editTargetAria": "Change the target for {{category}}",
|
||||||
"txCount_one": "{{count}} transaction",
|
"txCount_one": "{{count}} transaction",
|
||||||
"txCount_other": "{{count}} transactions",
|
"txCount_other": "{{count}} transactions",
|
||||||
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
|
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
|
||||||
|
|
@ -1579,20 +1597,26 @@
|
||||||
"byVehicle": "By envelope"
|
"byVehicle": "By envelope"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"hub": {
|
||||||
"title": "Get started with your balance sheet",
|
"getStarted": "Get started",
|
||||||
"subtitle": "Two steps to start tracking your net worth.",
|
"manage": "Manage",
|
||||||
"doneBadge": "Done",
|
"accounts": {
|
||||||
"step1": {
|
"title": "Manage accounts",
|
||||||
"title": "Create an account",
|
"description": "Create, edit, or archive your accounts and asset types."
|
||||||
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
|
|
||||||
"cta": "Create an account"
|
|
||||||
},
|
},
|
||||||
"step2": {
|
"snapshot": {
|
||||||
"title": "Enter a snapshot",
|
"title": "New snapshot",
|
||||||
"description": "A snapshot is the picture, at a given date, of the balance in each account. Enter one a month to track changes over time.",
|
"description": "Record the balance of each account at a given date."
|
||||||
"cta": "Enter a snapshot",
|
}
|
||||||
"disabledHint": "Create an account first to unlock this step."
|
},
|
||||||
|
"landing": {
|
||||||
|
"empty": {
|
||||||
|
"title": "Track your net worth",
|
||||||
|
"subtitle": "Start by creating an account: chequing, TFSA, RRSP, stocks, crypto, and so on."
|
||||||
|
},
|
||||||
|
"noSnapshot": {
|
||||||
|
"title": "Take your first snapshot",
|
||||||
|
"subtitle": "Your accounts are ready. Enter a snapshot to start tracking your net worth over time — or keep managing your accounts."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"starters": {
|
"starters": {
|
||||||
|
|
@ -1795,6 +1819,25 @@
|
||||||
"stock": "Stock",
|
"stock": "Stock",
|
||||||
"crypto": "Crypto"
|
"crypto": "Crypto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"importCsv": {
|
||||||
|
"button": "Import CSV",
|
||||||
|
"title": "Import securities from a CSV",
|
||||||
|
"intro": "Import the positions of \"{{account}}\" from a CSV file (broker statement, export). Columns are detected automatically; adjust them if needed.",
|
||||||
|
"selectFile": "Choose a CSV file",
|
||||||
|
"loading": "Reading file…",
|
||||||
|
"hint": "Accepted formats: .csv, .txt. Delimiter and encoding are detected automatically.",
|
||||||
|
"detectError": "Could not detect columns in this file. Make sure it is a valid securities CSV.",
|
||||||
|
"readError": "Could not read the file.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"symbolColumn": "Symbol column",
|
||||||
|
"quantityColumn": "Quantity column",
|
||||||
|
"priceColumn": "Price column (optional)",
|
||||||
|
"bookCostColumn": "Book cost column (optional)",
|
||||||
|
"noColumn": "— None —",
|
||||||
|
"noPriceNote": "No price column: securities will be imported without a price. You can fetch or type it afterwards.",
|
||||||
|
"summary": "{{count}} security(ies) will be imported.",
|
||||||
|
"import": "Import"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"balance": "Solde",
|
"kpiSectionTitle": "Indicateurs clés",
|
||||||
"income": "Revenus",
|
"income": "Revenus",
|
||||||
"expenses": "Dépenses",
|
"expenses": "Dépenses",
|
||||||
"net": "Net",
|
"net": "Net",
|
||||||
|
|
@ -50,10 +50,11 @@
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment utiliser le tableau de bord",
|
"title": "Comment utiliser le tableau de bord",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates",
|
"Le filtre en haut choisit la plage de dates et les sources d'import à inclure",
|
||||||
"Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée",
|
"Les indicateurs clés (revenus, dépenses, net, taux d'épargne) portent sur le mois de référence choisi, avec leur variation vs le mois précédent et vs l'an dernier",
|
||||||
"Le graphique circulaire détaille vos dépenses par catégorie",
|
"La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import",
|
||||||
"Les transactions récentes sont listées en bas de page"
|
"Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
|
||||||
|
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -379,6 +380,10 @@
|
||||||
"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"
|
||||||
|
|
@ -388,15 +393,18 @@
|
||||||
"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",
|
||||||
|
|
@ -427,7 +435,16 @@
|
||||||
"referenceMonth": "Mois de référence",
|
"referenceMonth": "Mois de référence",
|
||||||
"currentAmount": "Courant",
|
"currentAmount": "Courant",
|
||||||
"previousAmount": "Précédent",
|
"previousAmount": "Précédent",
|
||||||
"totalRow": "Total"
|
"sections": {
|
||||||
|
"expenses": "Dépenses",
|
||||||
|
"income": "Revenus",
|
||||||
|
"transfers": "Transferts"
|
||||||
|
},
|
||||||
|
"totalExpenses": "Total des dépenses",
|
||||||
|
"totalIncome": "Total des revenus",
|
||||||
|
"totalTransfers": "Total des transferts",
|
||||||
|
"resultBeforeTransfers": "Résultat avant transferts",
|
||||||
|
"resultNet": "Résultat net"
|
||||||
},
|
},
|
||||||
"cartes": {
|
"cartes": {
|
||||||
"kpiSectionAria": "Indicateurs clés du mois de référence",
|
"kpiSectionAria": "Indicateurs clés du mois de référence",
|
||||||
|
|
@ -1435,6 +1452,7 @@
|
||||||
"loadError": "Impossible de charger les données du profil : {{error}}",
|
"loadError": "Impossible de charger les données du profil : {{error}}",
|
||||||
"needsReview": "À réviser",
|
"needsReview": "À réviser",
|
||||||
"chooseTarget": "Choisir une cible...",
|
"chooseTarget": "Choisir une cible...",
|
||||||
|
"editTargetAria": "Modifier la cible pour {{category}}",
|
||||||
"txCount_one": "{{count}} transaction",
|
"txCount_one": "{{count}} transaction",
|
||||||
"txCount_other": "{{count}} transactions",
|
"txCount_other": "{{count}} transactions",
|
||||||
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
|
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
|
||||||
|
|
@ -1579,20 +1597,26 @@
|
||||||
"byVehicle": "Par enveloppe"
|
"byVehicle": "Par enveloppe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"hub": {
|
||||||
"title": "Premiers pas avec le bilan",
|
"getStarted": "Commencer",
|
||||||
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.",
|
"manage": "Gérer",
|
||||||
"doneBadge": "Fait",
|
"accounts": {
|
||||||
"step1": {
|
"title": "Gérer les comptes",
|
||||||
"title": "Créer un compte",
|
"description": "Créez, modifiez ou archivez vos comptes et vos types d'actif."
|
||||||
"description": "Un compte représente l'endroit où vous tenez votre argent : compte chèque, CELI, REER, actions, crypto, etc.",
|
|
||||||
"cta": "Créer un compte"
|
|
||||||
},
|
},
|
||||||
"step2": {
|
"snapshot": {
|
||||||
"title": "Saisir un snapshot",
|
"title": "Nouveau snapshot",
|
||||||
"description": "Un snapshot est la photo, à une date donnée, du solde de chaque compte. Saisissez-en un par mois pour suivre l'évolution.",
|
"description": "Enregistrez le solde de chaque compte à une date donnée."
|
||||||
"cta": "Saisir un snapshot",
|
}
|
||||||
"disabledHint": "Créez d'abord un compte pour activer cette étape."
|
},
|
||||||
|
"landing": {
|
||||||
|
"empty": {
|
||||||
|
"title": "Suivez votre valeur nette",
|
||||||
|
"subtitle": "Commencez par créer un compte : compte chèque, CELI, REER, actions, crypto, etc."
|
||||||
|
},
|
||||||
|
"noSnapshot": {
|
||||||
|
"title": "Saisissez votre premier snapshot",
|
||||||
|
"subtitle": "Vos comptes sont prêts. Saisissez un snapshot pour suivre l'évolution de votre valeur nette dans le temps — ou continuez à gérer vos comptes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"starters": {
|
"starters": {
|
||||||
|
|
@ -1795,6 +1819,25 @@
|
||||||
"stock": "Action",
|
"stock": "Action",
|
||||||
"crypto": "Crypto"
|
"crypto": "Crypto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"importCsv": {
|
||||||
|
"button": "Importer un CSV",
|
||||||
|
"title": "Importer des titres depuis un CSV",
|
||||||
|
"intro": "Importez les positions de « {{account}} » depuis un fichier CSV (relevé de courtier, exportation). Les colonnes sont détectées automatiquement ; ajustez-les au besoin.",
|
||||||
|
"selectFile": "Choisir un fichier CSV",
|
||||||
|
"loading": "Lecture du fichier…",
|
||||||
|
"hint": "Formats acceptés : .csv, .txt. Le séparateur et l'encodage sont détectés automatiquement.",
|
||||||
|
"detectError": "Impossible de détecter les colonnes dans ce fichier. Vérifiez qu'il s'agit d'un CSV de titres valide.",
|
||||||
|
"readError": "Impossible de lire le fichier.",
|
||||||
|
"retry": "Réessayer",
|
||||||
|
"symbolColumn": "Colonne du symbole",
|
||||||
|
"quantityColumn": "Colonne de la quantité",
|
||||||
|
"priceColumn": "Colonne du cours (facultatif)",
|
||||||
|
"bookCostColumn": "Colonne du coût d'acquisition (facultatif)",
|
||||||
|
"noColumn": "— Aucune —",
|
||||||
|
"noPriceNote": "Aucune colonne de cours : les titres seront importés sans prix. Vous pourrez le récupérer ou le saisir ensuite.",
|
||||||
|
"summary": "{{count}} titre(s) seront importé(s).",
|
||||||
|
"import": "Importer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet, FilePlus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useBalanceOverview,
|
useBalanceOverview,
|
||||||
type BalancePeriod,
|
type BalancePeriod,
|
||||||
|
|
@ -30,12 +30,15 @@ import {
|
||||||
import { getAllCategories } from "../services/transactionService";
|
import { getAllCategories } from "../services/transactionService";
|
||||||
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
||||||
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||||
import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard";
|
|
||||||
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||||
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||||
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
||||||
import DetailAccountWizard from "../components/balance/DetailAccountWizard";
|
import DetailAccountWizard from "../components/balance/DetailAccountWizard";
|
||||||
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
||||||
|
import HubReportNavCard, {
|
||||||
|
type HubReportNavCardProps,
|
||||||
|
} from "../components/reports/HubReportNavCard";
|
||||||
|
import { deriveLandingState } from "../components/balance/balanceLanding";
|
||||||
import { getPreference, setPreference } from "../services/userPreferenceService";
|
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||||
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
||||||
|
|
||||||
|
|
@ -195,25 +198,88 @@ export default function BalancePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Issue #178 — empty-state guard. We probe accountsLatest for ANY
|
{/* Issue #244 — landing state. We probe accountsLatest for ANY snapshot
|
||||||
snapshot date so the guard is independent of the active period
|
date (independent of the active period filter, state.period) and
|
||||||
filter (state.period). When empty, we render only the onboarding
|
derive one of three states via `deriveLandingState`. The old boolean
|
||||||
card — period selector, chart and accounts table would all show
|
guard conflated "no account" with "accounts but no snapshot" and
|
||||||
empty states stacked under it (S2 from #187). */}
|
stranded the latter on a snapshot-only onboarding card, leaving the
|
||||||
|
account-management page (/balance/accounts) unreachable. We now render
|
||||||
|
navigation tiles (HubReportNavCard, modeled on the Reports hub) so
|
||||||
|
account management is reachable in every state, and only the
|
||||||
|
"populated" state (>=1 account WITH a snapshot) renders the full
|
||||||
|
dashboard — otherwise period selector, chart and accounts table would
|
||||||
|
all stack empty states (S2 from #187). */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const accountsCount = state.accountsLatest.length;
|
const accountsCount = state.accountsLatest.length;
|
||||||
const hasAnySnapshot = state.accountsLatest.some(
|
const hasAnySnapshot = state.accountsLatest.some(
|
||||||
(a) => a.latest_snapshot_date != null
|
(a) => a.latest_snapshot_date != null
|
||||||
);
|
);
|
||||||
const isEmpty = accountsCount === 0 || !hasAnySnapshot;
|
const landingState = deriveLandingState(accountsCount, hasAnySnapshot);
|
||||||
|
|
||||||
if (isEmpty) {
|
// Navigation tiles reused across states. "Manage accounts" is present
|
||||||
|
// everywhere so /balance/accounts is reachable at all times (#244); the
|
||||||
|
// snapshot tile is withheld in the empty state (nothing to snapshot).
|
||||||
|
const accountsTile: HubReportNavCardProps = {
|
||||||
|
to: "/balance/accounts",
|
||||||
|
icon: <Wallet size={24} />,
|
||||||
|
title: t("balance.hub.accounts.title"),
|
||||||
|
description: t("balance.hub.accounts.description"),
|
||||||
|
};
|
||||||
|
const snapshotTile: HubReportNavCardProps = {
|
||||||
|
to: "/balance/snapshot",
|
||||||
|
icon: <FilePlus size={24} />,
|
||||||
|
title: t("balance.hub.snapshot.title"),
|
||||||
|
description: t("balance.hub.snapshot.description"),
|
||||||
|
};
|
||||||
|
const renderHub = (
|
||||||
|
tiles: HubReportNavCardProps[],
|
||||||
|
headingKey: string
|
||||||
|
) => (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
||||||
|
{t(headingKey)}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{tiles.map((card) => (
|
||||||
|
<HubReportNavCard key={card.to} {...card} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (landingState === "empty") {
|
||||||
|
// No account yet. StarterAccountsModal auto-opens (one-shot, #179);
|
||||||
|
// once dismissed the "Manage accounts" tile keeps account creation
|
||||||
|
// reachable. The snapshot tile is withheld — nothing to snapshot yet.
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<BalanceOnboardingCard
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
accountsCount={accountsCount}
|
<h2 className="text-lg font-semibold mb-1">
|
||||||
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
{t("balance.landing.empty.title")}
|
||||||
/>
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.landing.empty.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{renderHub([accountsTile], "balance.hub.getStarted")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (landingState === "accounts-no-snapshot") {
|
||||||
|
// Accounts exist but no snapshot. Offer BOTH paths (manage accounts
|
||||||
|
// OR enter a snapshot) without forcing the snapshot flow (#244).
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">
|
||||||
|
{t("balance.landing.noSnapshot.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.landing.noSnapshot.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{renderHub([accountsTile, snapshotTile], "balance.hub.getStarted")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +398,8 @@ export default function BalancePage() {
|
||||||
onDetailAccount={(acc) => setDetailTarget(acc)}
|
onDetailAccount={(acc) => setDetailTarget(acc)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderHub([accountsTile, snapshotTile], "balance.hub.manage")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
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,
|
||||||
|
|
@ -18,28 +21,32 @@ export default function BudgetPage() {
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
} = useBudget();
|
} = useBudget();
|
||||||
|
|
||||||
const { year, rows, templates, isLoading, isSaving, error } = state;
|
const { year, rows, templates, accounts, 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-6">
|
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
<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>
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
<TemplateActions
|
||||||
<TemplateActions
|
templates={templates}
|
||||||
templates={templates}
|
onApply={applyTemplate}
|
||||||
onApply={applyTemplate}
|
onApplyAllMonths={applyTemplateAllMonths}
|
||||||
onApplyAllMonths={applyTemplateAllMonths}
|
onSave={saveTemplate}
|
||||||
onSave={saveTemplate}
|
onDelete={deleteTemplate}
|
||||||
onDelete={deleteTemplate}
|
disabled={isSaving}
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,40 @@
|
||||||
|
// DashboardPage — home page at `/`.
|
||||||
|
//
|
||||||
|
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
|
||||||
|
// row, top movers and budget adherence are the exact Cartes presentational
|
||||||
|
// components, sourced from the same `getCartesSnapshot` reference-month
|
||||||
|
// snapshot. Two Dashboard-specific widgets remain: the top-expense-categories
|
||||||
|
// ranked bar chart (replacing the former expense-only pie) and the net-worth
|
||||||
|
// tile (from the Bilan — a different metric from every transactional widget
|
||||||
|
// here, see NetWorthTile's own header comment).
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
||||||
|
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
||||||
|
import FilterPanel from "../components/reports/FilterPanel";
|
||||||
|
import KpiCard from "../components/reports/cards/KpiCard";
|
||||||
|
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
||||||
|
import TopMoversList from "../components/reports/cards/TopMoversList";
|
||||||
|
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
import type { CategoryBreakdownItem } from "../shared/types";
|
import type { CategoryBreakdownItem } from "../shared/types";
|
||||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
|
const {
|
||||||
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
|
state,
|
||||||
|
setPeriod,
|
||||||
|
setCustomDates,
|
||||||
|
setReferencePeriod,
|
||||||
|
setAccountIds,
|
||||||
|
} = useDashboard();
|
||||||
|
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -37,35 +54,6 @@ export default function DashboardPage() {
|
||||||
setDetailModal(item);
|
setDetailModal(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const balance = summary.totalAmount;
|
|
||||||
const balanceColor =
|
|
||||||
balance > 0
|
|
||||||
? "text-[var(--positive)]"
|
|
||||||
: balance < 0
|
|
||||||
? "text-[var(--negative)]"
|
|
||||||
: "text-[var(--primary)]";
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
labelKey: "dashboard.balance",
|
|
||||||
value: fmt.format(balance),
|
|
||||||
icon: Wallet,
|
|
||||||
color: balanceColor,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
labelKey: "dashboard.income",
|
|
||||||
value: fmt.format(summary.incomeTotal),
|
|
||||||
icon: TrendingUp,
|
|
||||||
color: "text-[var(--positive)]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
labelKey: "dashboard.expenses",
|
|
||||||
value: fmt.format(Math.abs(summary.expenseTotal)),
|
|
||||||
icon: TrendingDown,
|
|
||||||
color: "text-[var(--negative)]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
@ -73,68 +61,117 @@ export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<CategoriesV1DiscoveryBanner />
|
<CategoriesV1DiscoveryBanner />
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
<div className="relative flex items-center gap-3 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||||
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
<PageHelp helpKey="dashboard" />
|
||||||
<PageHelp helpKey="dashboard" />
|
</div>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||||
|
{state.error}
|
||||||
</div>
|
</div>
|
||||||
<PeriodSelector
|
)}
|
||||||
value={period}
|
|
||||||
onChange={setPeriod}
|
|
||||||
customDateFrom={state.customDateFrom}
|
|
||||||
customDateTo={state.customDateTo}
|
|
||||||
onCustomDateChange={setCustomDates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<FilterPanel
|
||||||
{cards.map((card) => (
|
temporalControl={
|
||||||
<div
|
<PeriodSelector
|
||||||
key={card.labelKey}
|
value={period}
|
||||||
className="bg-[var(--card)] rounded-xl p-5 border border-[var(--border)] shadow-sm"
|
onChange={setPeriod}
|
||||||
>
|
customDateFrom={state.customDateFrom}
|
||||||
<div className="flex items-center justify-between mb-3">
|
customDateTo={state.customDateTo}
|
||||||
<span className="text-sm text-[var(--muted-foreground)]">
|
onCustomDateChange={setCustomDates}
|
||||||
{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">
|
accountIds={state.accountIds}
|
||||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap">
|
onAccountIdsChange={setAccountIds}
|
||||||
{t("reports.bva.titlePrefix")}
|
accounts={state.accounts}
|
||||||
<select
|
/>
|
||||||
value={`${state.budgetYear}-${state.budgetMonth}`}
|
|
||||||
onChange={(e) => {
|
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
const [y, m] = e.target.value.split("-").map(Number);
|
<h2 className="text-lg font-semibold">{t("dashboard.kpiSectionTitle")}</h2>
|
||||||
setBudgetMonth(y, m);
|
<select
|
||||||
}}
|
value={`${state.referenceYear}-${state.referenceMonth}`}
|
||||||
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"
|
onChange={(e) => {
|
||||||
>
|
const [y, m] = e.target.value.split("-").map(Number);
|
||||||
{monthOptions.map((opt) => (
|
setReferencePeriod(y, m);
|
||||||
<option key={opt.key} value={opt.value}>
|
}}
|
||||||
{opt.label}
|
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"
|
||||||
</option>
|
>
|
||||||
))}
|
{monthOptions.map((opt) => (
|
||||||
</select>
|
<option key={opt.key} value={opt.value}>
|
||||||
</h2>
|
{opt.label}
|
||||||
<BudgetVsActualTable data={budgetVsActual} />
|
</option>
|
||||||
</div>
|
))}
|
||||||
|
</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="mb-6">
|
<div className="mb-6">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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";
|
||||||
|
|
@ -25,7 +26,8 @@ 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 } = useReportsPeriod();
|
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } =
|
||||||
|
useReportsPeriod();
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
subMode,
|
subMode,
|
||||||
|
|
@ -35,6 +37,7 @@ export default function ReportsComparePage() {
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
rows,
|
rows,
|
||||||
|
accounts,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useCompare();
|
} = useCompare();
|
||||||
|
|
@ -90,21 +93,25 @@ export default function ReportsComparePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
<FilterPanel
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
temporalControl={
|
||||||
<CompareReferenceMonthPicker
|
<CompareReferenceMonthPicker
|
||||||
year={year}
|
year={year}
|
||||||
month={month}
|
month={month}
|
||||||
onChange={setReferencePeriod}
|
onChange={setReferencePeriod}
|
||||||
/>
|
/>
|
||||||
{showActualControls && (
|
}
|
||||||
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
|
accountIds={accountIds}
|
||||||
)}
|
onAccountIdsChange={setAccountIds}
|
||||||
</div>
|
accounts={accounts}
|
||||||
{showActualControls && (
|
/>
|
||||||
|
|
||||||
|
{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} />
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
)}
|
</div>
|
||||||
</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">
|
||||||
|
|
@ -113,7 +120,7 @@ export default function ReportsComparePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === "budget" ? (
|
{mode === "budget" ? (
|
||||||
<CompareBudgetView year={year} month={month} />
|
<CompareBudgetView year={year} month={month} accountIds={accountIds} />
|
||||||
) : viewMode === "chart" ? (
|
) : viewMode === "chart" ? (
|
||||||
<ComparePeriodChart
|
<ComparePeriodChart
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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";
|
||||||
|
|
@ -21,9 +22,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 } = useReportsPeriod();
|
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
|
||||||
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
|
const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
|
||||||
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());
|
||||||
|
|
||||||
|
|
@ -55,50 +56,56 @@ 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>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
<FilterPanel
|
||||||
<PeriodSelector
|
temporalControl={
|
||||||
value={period}
|
<PeriodSelector
|
||||||
onChange={setPeriod}
|
value={period}
|
||||||
customDateFrom={from}
|
onChange={setPeriod}
|
||||||
customDateTo={to}
|
customDateFrom={from}
|
||||||
onCustomDateChange={setCustomDates}
|
customDateTo={to}
|
||||||
/>
|
onCustomDateChange={setCustomDates}
|
||||||
<div className="flex gap-2 items-center flex-wrap">
|
/>
|
||||||
<div className="inline-flex gap-1">
|
}
|
||||||
<button
|
accountIds={accountIds}
|
||||||
type="button"
|
onAccountIdsChange={setAccountIds}
|
||||||
onClick={() => setSubView("global")}
|
accounts={accounts}
|
||||||
aria-pressed={subView === "global"}
|
/>
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
subView === "global"
|
<div className="flex gap-2 items-center justify-end flex-wrap mb-6">
|
||||||
? "bg-[var(--primary)] text-white"
|
<div className="inline-flex gap-1">
|
||||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
<button
|
||||||
}`}
|
type="button"
|
||||||
>
|
onClick={() => setSubView("global")}
|
||||||
{t("reports.trends.subviewGlobal")}
|
aria-pressed={subView === "global"}
|
||||||
</button>
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
<button
|
subView === "global"
|
||||||
type="button"
|
? "bg-[var(--primary)] text-white"
|
||||||
onClick={() => setSubView("byCategory")}
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
aria-pressed={subView === "byCategory"}
|
}`}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
>
|
||||||
subView === "byCategory"
|
{t("reports.trends.subviewGlobal")}
|
||||||
? "bg-[var(--primary)] text-white"
|
</button>
|
||||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
<button
|
||||||
}`}
|
type="button"
|
||||||
>
|
onClick={() => setSubView("byCategory")}
|
||||||
{t("reports.trends.subviewByCategory")}
|
aria-pressed={subView === "byCategory"}
|
||||||
</button>
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
</div>
|
subView === "byCategory"
|
||||||
{subView === "byCategory" && viewMode === "chart" && (
|
? "bg-[var(--primary)] text-white"
|
||||||
<TrendsChartTypeToggle
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
value={chartType}
|
}`}
|
||||||
onChange={setChartType}
|
>
|
||||||
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
|
{t("reports.trends.subviewByCategory")}
|
||||||
/>
|
</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 && (
|
||||||
|
|
@ -123,7 +130,7 @@ export default function ReportsTrendsPage() {
|
||||||
chartType={chartType}
|
chartType={chartType}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
|
<CategoryOverTimeTable data={categoryOverTime} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,7 @@ export default function SnapshotEditPage() {
|
||||||
onRemoveHolding={editor.removeHolding}
|
onRemoveHolding={editor.removeHolding}
|
||||||
onHoldingFieldChange={editor.setHoldingField}
|
onHoldingFieldChange={editor.setHoldingField}
|
||||||
onHoldingSecurityPick={editor.setHoldingSecurity}
|
onHoldingSecurityPick={editor.setHoldingSecurity}
|
||||||
|
onImportHoldings={editor.importHoldings}
|
||||||
disabled={state.isSaving}
|
disabled={state.isSaving}
|
||||||
snapshotDate={state.snapshotDate}
|
snapshotDate={state.snapshotDate}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
120
src/services/budgetService.test.ts
Normal file
120
src/services/budgetService.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { inPlaceholders } from "../utils/sqlFilters";
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
BudgetEntry,
|
BudgetEntry,
|
||||||
|
|
@ -181,34 +182,49 @@ 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);
|
return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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
|
WHERE date BETWEEN $1 AND $2${accountFilter}
|
||||||
GROUP BY category_id`,
|
GROUP BY category_id`,
|
||||||
[dateFrom, dateTo]
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
// Income-statement reading order: revenue first, then expenses, then
|
||||||
|
// transfers (Issue #277 — matches COMPARE_TYPE_ORDER / OVER_TIME_TYPE_ORDER).
|
||||||
|
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
|
||||||
|
* (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);
|
||||||
|
|
@ -219,8 +235,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),
|
getActualsByCategoryRange(monthFrom, monthTo, accountIds),
|
||||||
getActualsByCategoryRange(ytdFrom, ytdTo),
|
getActualsByCategoryRange(ytdFrom, ytdTo, accountIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build maps
|
// Build maps
|
||||||
|
|
|
||||||
113
src/services/dashboardService.test.ts
Normal file
113
src/services/dashboardService.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
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,
|
||||||
|
|
@ -54,7 +57,7 @@ export async function getDashboardSummary(
|
||||||
export async function getExpensesByCategory(
|
export async function getExpensesByCategory(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
sourceId?: number,
|
accountIds?: number[],
|
||||||
): Promise<CategoryBreakdownItem[]> {
|
): Promise<CategoryBreakdownItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -72,10 +75,11 @@ export async function getExpensesByCategory(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
if (sourceId != null) {
|
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
if (accountPlaceholders) {
|
||||||
params.push(sourceId);
|
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
|
||||||
paramIndex++;
|
params.push(...accountIds!);
|
||||||
|
paramIndex += accountIds!.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
@ -162,3 +166,47 @@ export async function getRecentTransactions(
|
||||||
[limit]
|
[limit]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Net-worth tile (Issue #279) ---
|
||||||
|
//
|
||||||
|
// The Dashboard's net-worth tile surfaces the Bilan's latest total
|
||||||
|
// (`getSnapshotTotalsByDate`, balance.service.ts:1767) — a DIFFERENT metric
|
||||||
|
// from every other Dashboard widget: it is the latest snapshot of account
|
||||||
|
// balances, not a transaction P&L. It must never be confused with (or scoped
|
||||||
|
// by) the transactional account/import-source filter, and must never render
|
||||||
|
// a misleading "$0" before any snapshot has ever been recorded.
|
||||||
|
|
||||||
|
export interface NetWorthTileData {
|
||||||
|
visible: boolean;
|
||||||
|
total: number | null;
|
||||||
|
asOfDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_NET_WORTH_TILE: NetWorthTileData = {
|
||||||
|
visible: false,
|
||||||
|
total: null,
|
||||||
|
asOfDate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes the net-worth tile from the same two signals BalancePage's landing
|
||||||
|
* state already uses (`deriveLandingState`) — reused here rather than
|
||||||
|
* re-inferring "no data" from nulls, per the #279 review caveat. The tile
|
||||||
|
* stays hidden (never a misleading "$0") unless at least one balance account
|
||||||
|
* exists AND at least one snapshot has ever been recorded; `totals` (the
|
||||||
|
* `getSnapshotTotalsByDate` series, ascending by date) is otherwise expected
|
||||||
|
* to carry at least one point, but a defensive check guards a theoretical
|
||||||
|
* mismatch between the two independent queries.
|
||||||
|
*/
|
||||||
|
export function deriveNetWorthTile(
|
||||||
|
accountsCount: number,
|
||||||
|
hasAnySnapshot: boolean,
|
||||||
|
totals: SnapshotTotalPoint[]
|
||||||
|
): NetWorthTileData {
|
||||||
|
if (deriveLandingState(accountsCount, hasAnySnapshot) !== "populated") {
|
||||||
|
return EMPTY_NET_WORTH_TILE;
|
||||||
|
}
|
||||||
|
const latest = totals[totals.length - 1];
|
||||||
|
if (!latest) return EMPTY_NET_WORTH_TILE;
|
||||||
|
return { visible: true, total: latest.total, asOfDate: latest.snapshot_date };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,33 @@ describe("getCartesSnapshot", () => {
|
||||||
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
|
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes income from top movers so a salary rise is not a red 'increase' (#253)", async () => {
|
||||||
|
// Post-#253 COMPARE_DELTA_SQL surfaces income as a signed positive SUM, so
|
||||||
|
// revenue rows now reach getCartesSnapshot. Top movers is a spending view
|
||||||
|
// (up = red), so income must be filtered out — otherwise a salary jump tops
|
||||||
|
// "biggest increases" in red. The category metadata query types id 99 as
|
||||||
|
// income; the two expense movers are absent from it and default to expense.
|
||||||
|
const momRows = [
|
||||||
|
{ category_id: 99, category_name: "Salaire", category_color: "#000", month_current_total: 5000, month_previous_total: 100, cumulative_current_total: 5000, cumulative_previous_total: 100 },
|
||||||
|
{ category_id: 1, category_name: "Épicerie", category_color: "#000", month_current_total: 300, month_previous_total: 100, cumulative_current_total: 300, cumulative_previous_total: 100 },
|
||||||
|
{ category_id: 2, category_name: "Resto", category_color: "#000", month_current_total: 100, month_previous_total: 400, cumulative_current_total: 100, cumulative_previous_total: 400 },
|
||||||
|
];
|
||||||
|
routeSelect([
|
||||||
|
{ match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 5000, expenses: 400 }] },
|
||||||
|
{ match: "ORDER BY ABS(month_current_total - month_previous_total) DESC", rows: momRows },
|
||||||
|
{ match: "parent_id FROM categories", rows: [{ id: 99, name: "Salaire", color: null, type: "income", parent_id: null }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
const upNames = snapshot.topMoversUp.map((m) => m.categoryName);
|
||||||
|
// Income excluded despite the biggest delta (+4900).
|
||||||
|
expect(upNames).not.toContain("Salaire");
|
||||||
|
// The biggest EXPENSE increase leads instead.
|
||||||
|
expect(snapshot.topMoversUp[0].categoryName).toBe("Épicerie");
|
||||||
|
// Expense decrease still surfaces.
|
||||||
|
expect(snapshot.topMoversDown[0].categoryName).toBe("Resto");
|
||||||
|
});
|
||||||
|
|
||||||
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
|
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
|
||||||
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
|
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
|
||||||
routeSelect([
|
routeSelect([
|
||||||
|
|
@ -442,4 +469,77 @@ 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
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,
|
||||||
|
buildOverTimeTree,
|
||||||
} from "./reportService";
|
} from "./reportService";
|
||||||
|
import type { CategoryDelta, OverTimeRow } from "../shared/types";
|
||||||
|
|
||||||
// Mock the db module
|
// Mock the db module
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => {
|
||||||
|
|
@ -26,6 +30,66 @@ 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
|
||||||
|
|
@ -84,74 +148,171 @@ describe("getCategoryOverTime", () => {
|
||||||
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies sourceId filter", async () => {
|
it("applies accountIds filter (single id)", 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");
|
expect(topCatSQL).toContain("t.source_id IN ($1)");
|
||||||
expect(topCatParams[0]).toBe(3);
|
expect(topCatParams[0]).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("combines typeFilter, date range, and sourceId", async () => {
|
it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([]) // topCategories
|
.mockResolvedValueOnce([]) // topCategories
|
||||||
.mockResolvedValueOnce([]); // monthlyRows
|
.mockResolvedValueOnce([]); // monthlyRows
|
||||||
|
|
||||||
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense");
|
await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]);
|
||||||
|
|
||||||
|
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 = $4");
|
expect(topCatSQL).toContain("t.source_id IN ($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]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns correct structure with categories, data, colors, and categoryIds", async () => {
|
it("projects category type into the top-N SELECT", async () => {
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([]) // topCategories
|
||||||
|
.mockResolvedValueOnce([]); // monthlyRows
|
||||||
|
|
||||||
|
await getCategoryOverTime();
|
||||||
|
|
||||||
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') AS category_type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct structure with categories, data, colors, categoryIds, and types", async () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
|
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
||||||
{ category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 },
|
{ category_id: 2, category_name: "Salary", category_color: "#00ff00", category_type: "income", total: 200 },
|
||||||
])
|
])
|
||||||
.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: "Salary", total: 100 },
|
||||||
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
|
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
|
||||||
{ month: "2025-02", category_id: 2, category_name: "Transport", total: 100 },
|
{ month: "2025-02", category_id: 2, category_name: "Salary", total: 100 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
|
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
|
||||||
|
|
||||||
expect(result.categories).toEqual(["Food", "Transport"]);
|
expect(result.categories).toEqual(["Food", "Salary"]);
|
||||||
expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" });
|
expect(result.colors).toEqual({ Food: "#ff0000", Salary: "#00ff00" });
|
||||||
expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 });
|
expect(result.categoryIds).toEqual({ Food: 1, Salary: 2 });
|
||||||
|
expect(result.types).toEqual({ Food: "expense", Salary: "income" });
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 });
|
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 100 });
|
||||||
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 });
|
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Salary: 100 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups non-top-N categories into Other", async () => {
|
it("defaults a null category type to expense in the types map", async () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
|
// COALESCE(c.type, 'expense') yields 'expense'; a null slipping through defaults too.
|
||||||
|
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: null, total: 500 },
|
||||||
])
|
])
|
||||||
|
.mockResolvedValueOnce([{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }]);
|
||||||
|
|
||||||
|
const result = await getCategoryOverTime();
|
||||||
|
|
||||||
|
expect(result.types).toEqual({ Food: "expense" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => {
|
||||||
|
mockSelect
|
||||||
|
// 1. top categories — top-N = 1, so only Food.
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ 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.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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -325,7 +486,10 @@ describe("getCompareMonthOverMonth", () => {
|
||||||
|
|
||||||
await getCompareMonthOverMonth(2026, 4);
|
await getCompareMonthOverMonth(2026, 4);
|
||||||
|
|
||||||
expect(mockSelect).toHaveBeenCalledTimes(1);
|
// Two queries now: the delta aggregation (call 0, unchanged) + the category
|
||||||
|
// metadata for the hierarchy (call 1, Issue #247).
|
||||||
|
expect(mockSelect).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
expect(sql).toContain("$1");
|
expect(sql).toContain("$1");
|
||||||
|
|
@ -417,6 +581,46 @@ 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", () => {
|
||||||
|
|
@ -474,6 +678,152 @@ 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)", () => {
|
||||||
|
// A `transfer` category (e.g. "Paiement CC") is a money move, not spending:
|
||||||
|
// its debit and matching credit must cancel to ~0 in the real-vs-real
|
||||||
|
// compare. Ordinary categories keep the expense-report behavior (only
|
||||||
|
// outflows, summed as positive magnitudes). The netting lives entirely in
|
||||||
|
// the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`,
|
||||||
|
// ABS(t.amount) filtered on amount < 0 for plain expenses. Income, like
|
||||||
|
// transfers, is a signed SUM (Issue #253); the WHERE is broadened so
|
||||||
|
// income/transfer credits are not dropped before they can count or cancel.
|
||||||
|
//
|
||||||
|
// There is no runnable SQLite in these unit tests (tauri-plugin-sql only
|
||||||
|
// exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so
|
||||||
|
// the netting is pinned two ways: (1) a reference-semantics check over
|
||||||
|
// concrete debit/credit rows that reproduces the exact per-row rule the SQL
|
||||||
|
// encodes, and (2) structural assertions that the real query on BOTH compare
|
||||||
|
// functions carries that rule and the broadened WHERE.
|
||||||
|
|
||||||
|
/** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
|
||||||
|
function bucketContribution(amount: number, type: string | null): number {
|
||||||
|
const t = type ?? "expense";
|
||||||
|
return t === "transfer" || t === "income"
|
||||||
|
? amount // signed → transfer debit/credit cancel; income credits kept as revenue (#253)
|
||||||
|
: amount < 0
|
||||||
|
? Math.abs(amount) // expense outflow as a positive magnitude
|
||||||
|
: 0; // other (expense) credits dropped — the report stays outflow-only for them
|
||||||
|
}
|
||||||
|
|
||||||
|
it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => {
|
||||||
|
// "Paiement CC" (transfer): -500 out of chequing, +500 onto the card.
|
||||||
|
const transfer = [-500, 500].reduce((s, a) => s + bucketContribution(a, "transfer"), 0);
|
||||||
|
expect(transfer).toBeCloseTo(0, 6);
|
||||||
|
|
||||||
|
// "Épicerie" (expense): two debits + one refund credit that must NOT net it down.
|
||||||
|
const expense = [-120, -80, 60].reduce((s, a) => s + bucketContribution(a, "expense"), 0);
|
||||||
|
expect(expense).toBe(200);
|
||||||
|
|
||||||
|
// Uncategorized (c.type NULL) defaults to expense behavior.
|
||||||
|
const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0);
|
||||||
|
expect(uncategorized).toBe(40);
|
||||||
|
|
||||||
|
// Income (paie + a small correction) is a signed SUM — credits are kept as
|
||||||
|
// revenue (Issue #253), not dropped like non-transfer expense credits.
|
||||||
|
const income = [4200, 150, -50].reduce((s, a) => s + bucketContribution(a, "income"), 0);
|
||||||
|
expect(income).toBe(4300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareMonthOverMonth(2026, 4);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
// transfer → signed t.amount; every other type → ABS(t.amount)
|
||||||
|
expect(sql).toContain(
|
||||||
|
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
|
||||||
|
);
|
||||||
|
// WHERE broadened so transfer credits survive the expense (amount < 0) filter
|
||||||
|
expect(sql).toContain(
|
||||||
|
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
|
||||||
|
);
|
||||||
|
// still four date buckets
|
||||||
|
expect(sql).toContain("month_current_total");
|
||||||
|
expect(sql).toContain("cumulative_previous_total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCompareYearOverYear applies the identical netting contract (both tabs fixed)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareYearOverYear(2026, 4);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain(
|
||||||
|
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
|
||||||
|
);
|
||||||
|
expect(sql).toContain(
|
||||||
|
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a netted transfer at 0 through the pipeline while an expense is unchanged", async () => {
|
||||||
|
// Rows as the netted SQL returns them: the balanced transfer arrives at 0,
|
||||||
|
// the ordinary expense keeps its outflow sum. Assert nothing downstream
|
||||||
|
// re-inflates the transfer.
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
category_id: 5,
|
||||||
|
category_name: "Épicerie",
|
||||||
|
category_color: "#10b981",
|
||||||
|
month_current_total: 200,
|
||||||
|
month_previous_total: 150,
|
||||||
|
cumulative_current_total: 800,
|
||||||
|
cumulative_previous_total: 600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category_id: 71,
|
||||||
|
category_name: "Paiement CC",
|
||||||
|
category_color: "#9ca3af",
|
||||||
|
month_current_total: 0,
|
||||||
|
month_previous_total: 0,
|
||||||
|
cumulative_current_total: 0,
|
||||||
|
cumulative_previous_total: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getCompareMonthOverMonth(2026, 4);
|
||||||
|
const transferRow = result.find((r) => r.categoryName === "Paiement CC")!;
|
||||||
|
const expenseRow = result.find((r) => r.categoryName === "Épicerie")!;
|
||||||
|
|
||||||
|
expect(transferRow.currentAmount).toBe(0);
|
||||||
|
expect(transferRow.previousAmount).toBe(0);
|
||||||
|
expect(transferRow.deltaAbs).toBe(0);
|
||||||
|
expect(transferRow.cumulativeCurrentAmount).toBe(0);
|
||||||
|
// Ordinary expense keeps EXACTLY its sum of outflows.
|
||||||
|
expect(expenseRow.currentAmount).toBe(200);
|
||||||
|
expect(expenseRow.previousAmount).toBe(150);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getCategoryZoom", () => {
|
describe("getCategoryZoom", () => {
|
||||||
|
|
@ -530,3 +880,277 @@ describe("getCategoryZoom", () => {
|
||||||
expect(txSql).toContain("t.category_id = $1");
|
expect(txSql).toContain("t.category_id = $1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
|
||||||
|
// Flat leaf, mirroring what rowsToDeltas returns from COMPARE_DELTA_SQL.
|
||||||
|
function leaf(
|
||||||
|
categoryId: number | null,
|
||||||
|
categoryName: string,
|
||||||
|
mc: number,
|
||||||
|
mp: number,
|
||||||
|
cc = mc,
|
||||||
|
cp = mp,
|
||||||
|
): CategoryDelta {
|
||||||
|
return {
|
||||||
|
categoryId,
|
||||||
|
categoryName,
|
||||||
|
categoryColor: "#000",
|
||||||
|
previousAmount: mp,
|
||||||
|
currentAmount: mc,
|
||||||
|
deltaAbs: mc - mp,
|
||||||
|
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
|
||||||
|
cumulativePreviousAmount: cp,
|
||||||
|
cumulativeCurrentAmount: cc,
|
||||||
|
cumulativeDeltaAbs: cc - cp,
|
||||||
|
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
|
||||||
|
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
|
||||||
|
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
|
||||||
|
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
|
||||||
|
] as Parameters<typeof buildCompareTree>[1];
|
||||||
|
|
||||||
|
it("nests leaves under a parent subtotal equal to the sum of its children", () => {
|
||||||
|
const rows = buildCompareTree(
|
||||||
|
[leaf(22, "Épicerie", 500, 400, 2000, 1500), leaf(24, "Restaurant", 120, 200, 300, 500)],
|
||||||
|
cats,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parent subtotal on top, then children ordered by |monthly delta| 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 = sum of children (the group's net).
|
||||||
|
expect(parent.currentAmount).toBe(620);
|
||||||
|
expect(parent.previousAmount).toBe(600);
|
||||||
|
expect(parent.deltaAbs).toBe(20);
|
||||||
|
expect(parent.cumulativeCurrentAmount).toBe(2300);
|
||||||
|
expect(parent.cumulativePreviousAmount).toBe(2000);
|
||||||
|
expect(parent.cumulativeDeltaAbs).toBe(300);
|
||||||
|
|
||||||
|
// Children are leaves at depth 1 with UNCHANGED values (no regression).
|
||||||
|
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
|
||||||
|
expect(epicerie.is_parent).toBeFalsy();
|
||||||
|
expect(epicerie.depth).toBe(1);
|
||||||
|
expect(epicerie.parent_id).toBe(2);
|
||||||
|
expect(epicerie.currentAmount).toBe(500);
|
||||||
|
expect(epicerie.previousAmount).toBe(400);
|
||||||
|
expect(epicerie.deltaAbs).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a balanced transfer group netted to 0 at the subtotal (preserves #243)", () => {
|
||||||
|
const rows = buildCompareTree([leaf(50, "REER", 0, 0, 0, 0)], cats);
|
||||||
|
const parent = rows.find((r) => r.categoryId === 5)!;
|
||||||
|
expect(parent.is_parent).toBe(true);
|
||||||
|
expect(parent.category_type).toBe("transfer");
|
||||||
|
// The netted-to-zero transfer stays 0 through the subtotal — not re-inflated.
|
||||||
|
expect(parent.currentAmount).toBe(0);
|
||||||
|
expect(parent.previousAmount).toBe(0);
|
||||||
|
expect(parent.deltaAbs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
|
||||||
|
const rows = buildCompareTree(
|
||||||
|
[leaf(80, "Paie", 4200, 4000), leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
|
||||||
|
cats,
|
||||||
|
);
|
||||||
|
const types = rows.map((r) => r.category_type);
|
||||||
|
// Income section first, then expenses, then transfers (Issue #253).
|
||||||
|
expect(types).toEqual(["income", "income", "expense", "expense", "transfer", "transfer"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves grand-total invariance vs the flat leaves", () => {
|
||||||
|
const flat = [
|
||||||
|
leaf(22, "Épicerie", 500, 400, 2000, 1500),
|
||||||
|
leaf(24, "Restaurant", 120, 200, 300, 500),
|
||||||
|
leaf(50, "REER", 0, 0, 0, 0),
|
||||||
|
];
|
||||||
|
const rows = buildCompareTree(flat, cats);
|
||||||
|
const sumLeaves = (rs: CategoryDelta[]) =>
|
||||||
|
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.currentAmount, 0);
|
||||||
|
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an uncategorized (null id) row as a top-level leaf", () => {
|
||||||
|
const rows = buildCompareTree([leaf(null, "Uncategorized", 90, 40)], cats);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].categoryName).toBe("Uncategorized");
|
||||||
|
expect(rows[0].is_parent).toBeFalsy();
|
||||||
|
expect(rows[0].depth).toBe(0);
|
||||||
|
expect(rows[0].currentAmount).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a leaf whose category was soft-deleted (still in the metadata)", () => {
|
||||||
|
// A single leaf with no siblings collapses to just that leaf (no lone subtotal).
|
||||||
|
const rows = buildCompareTree([leaf(22, "Épicerie", 75, 60)], cats);
|
||||||
|
const names = rows.map((r) => r.categoryName);
|
||||||
|
expect(names).toContain("Épicerie");
|
||||||
|
// A parent with exactly one contributing child still gets a subtotal row.
|
||||||
|
const parent = rows.find((r) => r.is_parent);
|
||||||
|
expect(parent?.currentAmount).toBe(75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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)", () => {
|
||||||
|
it("returns parent subtotal rows when category metadata is available", async () => {
|
||||||
|
mockSelect
|
||||||
|
// 1. COMPARE_DELTA_SQL — two sibling leaves under parent id 2.
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
category_id: 22,
|
||||||
|
category_name: "Épicerie",
|
||||||
|
category_color: "#10b981",
|
||||||
|
month_current_total: 500,
|
||||||
|
month_previous_total: 400,
|
||||||
|
cumulative_current_total: 2000,
|
||||||
|
cumulative_previous_total: 1500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category_id: 24,
|
||||||
|
category_name: "Restaurant",
|
||||||
|
category_color: "#f97316",
|
||||||
|
month_current_total: 120,
|
||||||
|
month_previous_total: 200,
|
||||||
|
cumulative_current_total: 300,
|
||||||
|
cumulative_previous_total: 500,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
// 2. COMPARE_CATEGORIES_SQL — the hierarchy.
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ 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 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getCompareMonthOverMonth(2026, 4);
|
||||||
|
|
||||||
|
// Delta query stays call 0 (netting SQL + params assertions elsewhere rely on it).
|
||||||
|
expect(mockSelect.mock.calls[0][0]).toContain("month_current_total");
|
||||||
|
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
|
||||||
|
|
||||||
|
const parent = result.find((r) => r.is_parent);
|
||||||
|
expect(parent).toBeDefined();
|
||||||
|
expect(parent!.categoryId).toBe(2);
|
||||||
|
expect(parent!.currentAmount).toBe(620);
|
||||||
|
// Leaves survive unchanged.
|
||||||
|
expect(result.find((r) => r.categoryName === "Épicerie")!.currentAmount).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
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,
|
||||||
|
|
@ -28,7 +30,7 @@ import type {
|
||||||
export async function getMonthlyTrends(
|
export async function getMonthlyTrends(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
sourceId?: number,
|
accountIds?: number[],
|
||||||
): Promise<MonthlyTrendItem[]> {
|
): Promise<MonthlyTrendItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -46,10 +48,11 @@ export async function getMonthlyTrends(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
if (sourceId != null) {
|
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||||
whereClauses.push(`source_id = $${paramIndex}`);
|
if (accountPlaceholders) {
|
||||||
params.push(sourceId);
|
whereClauses.push(`source_id IN (${accountPlaceholders})`);
|
||||||
paramIndex++;
|
params.push(...accountIds!);
|
||||||
|
paramIndex += accountIds!.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL =
|
const whereSQL =
|
||||||
|
|
@ -72,7 +75,7 @@ export async function getCategoryOverTime(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
topN: number = 50,
|
topN: number = 50,
|
||||||
sourceId?: number,
|
accountIds?: number[],
|
||||||
typeFilter?: "expense" | "income" | "transfer",
|
typeFilter?: "expense" | "income" | "transfer",
|
||||||
): Promise<CategoryOverTimeData> {
|
): Promise<CategoryOverTimeData> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
@ -97,20 +100,24 @@ export async function getCategoryOverTime(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
if (sourceId != null) {
|
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
if (accountPlaceholders) {
|
||||||
params.push(sourceId);
|
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
|
||||||
paramIndex++;
|
params.push(...accountIds!);
|
||||||
|
paramIndex += accountIds!.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
|
||||||
// Get top N categories by total spend
|
// Get top N categories by total spend
|
||||||
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
const topCategories = await db.select<
|
||||||
|
(CategoryBreakdownItem & { category_type: "expense" | "income" | "transfer" })[]
|
||||||
|
>(
|
||||||
`SELECT
|
`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,
|
||||||
|
COALESCE(c.type, 'expense') AS category_type,
|
||||||
ABS(SUM(t.amount)) AS total
|
ABS(SUM(t.amount)) AS total
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN categories c ON t.category_id = c.id
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
|
@ -124,9 +131,11 @@ export async function getCategoryOverTime(
|
||||||
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
||||||
const colors: Record<string, string> = {};
|
const colors: Record<string, string> = {};
|
||||||
const categoryIds: Record<string, number | null> = {};
|
const categoryIds: Record<string, number | null> = {};
|
||||||
|
const types: Record<string, "expense" | "income" | "transfer"> = {};
|
||||||
for (const cat of topCategories) {
|
for (const cat of topCategories) {
|
||||||
colors[cat.category_name] = cat.category_color;
|
colors[cat.category_name] = cat.category_color;
|
||||||
categoryIds[cat.category_name] = cat.category_id;
|
categoryIds[cat.category_name] = cat.category_id;
|
||||||
|
types[cat.category_name] = cat.category_type ?? "expense";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get monthly breakdown for all categories
|
// Get monthly breakdown for all categories
|
||||||
|
|
@ -178,11 +187,58 @@ 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: Array.from(monthMap.values()),
|
data,
|
||||||
colors,
|
colors,
|
||||||
categoryIds,
|
categoryIds,
|
||||||
|
types,
|
||||||
|
tree,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -429,21 +485,449 @@ 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) ---
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* name/color, income-statement `type`, and `parent_id` to walk the hierarchy.
|
||||||
|
* Shared by the compare tree (Issue #247) and the trends tree (Issue #264).
|
||||||
|
*/
|
||||||
|
export interface TreeCatMeta {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
type: TreeSectionType | null;
|
||||||
|
parent_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
|
||||||
|
const MAX_TREE_DEPTH = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection points that specialise the generic tree builder for a concrete row
|
||||||
|
* type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape
|
||||||
|
* emitted — leaf/subtotal rows just add the hierarchy fields (`parent_id`,
|
||||||
|
* `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
|
||||||
|
* against a corrupted parent_id cycle — is shared verbatim by every
|
||||||
|
* hierarchical report; the row arithmetic is injected via `opts`. Extracted
|
||||||
|
* from `buildCompareTree` (Issue #263) so the trends tree (Issue #264) reuses
|
||||||
|
* the exact same skeleton.
|
||||||
|
*/
|
||||||
|
export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<T>): T[] {
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
categoryIdOf,
|
||||||
|
makeLeaf,
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or
|
||||||
|
// absent from the table (hard-deleted) become orphans: depth-0 leaves
|
||||||
|
// preserved in their incoming order.
|
||||||
|
const leafByCat = new Map<number, T>();
|
||||||
|
const orphans: T[] = [];
|
||||||
|
for (const l of leaves) {
|
||||||
|
const id = categoryIdOf(l);
|
||||||
|
if (id != null && catById.has(id)) {
|
||||||
|
leafByCat.set(id, l);
|
||||||
|
} else {
|
||||||
|
orphans.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Relevant" = every category that has a leaf plus all of its ancestors.
|
||||||
|
const relevant = new Set<number>();
|
||||||
|
for (const id of leafByCat.keys()) {
|
||||||
|
let cur: number | null | undefined = id;
|
||||||
|
let guard = 0;
|
||||||
|
while (cur != null && guard <= maxDepth) {
|
||||||
|
if (relevant.has(cur)) break;
|
||||||
|
relevant.add(cur);
|
||||||
|
cur = catById.get(cur)?.parent_id ?? null;
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacency among relevant categories only, preserving DB order.
|
||||||
|
const childrenByParent = new Map<number, TreeCatMeta[]>();
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
|
||||||
|
let arr = childrenByParent.get(c.parent_id);
|
||||||
|
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
|
||||||
|
arr.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
rows: T[];
|
||||||
|
sortKey: number; // magnitude of the block head — orders siblings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a node's block: a pure leaf, or a subtotal followed by its
|
||||||
|
// (recursively built) child blocks, siblings ordered by magnitude desc.
|
||||||
|
const buildNode = (cat: TreeCatMeta, depth: number): Block | null => {
|
||||||
|
// Stop descending past the depth cap so a corrupted parent_id cycle can
|
||||||
|
// never recurse forever — deeper nodes collapse to leaves (real category
|
||||||
|
// trees are ≤ 3 levels).
|
||||||
|
const children = depth >= maxDepth ? [] : (childrenByParent.get(cat.id) ?? []);
|
||||||
|
const hasDirect = leafByCat.has(cat.id);
|
||||||
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
if (!hasDirect) return null;
|
||||||
|
const leaf = makeLeaf(cat, leafByCat.get(cat.id)!, cat.parent_id ?? null, depth);
|
||||||
|
return { rows: [leaf], sortKey: sortKey(leaf) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const childBlocks: Block[] = [];
|
||||||
|
// 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
|
||||||
|
// rows. Rare: a parent auto-loses is_inputable when a child is added.
|
||||||
|
if (hasDirect) {
|
||||||
|
const direct = decorateDirectLeaf(
|
||||||
|
makeLeaf(cat, leafByCat.get(cat.id)!, cat.id, depth + 1),
|
||||||
|
cat,
|
||||||
|
);
|
||||||
|
childBlocks.push({ rows: [direct], sortKey: sortKey(direct) });
|
||||||
|
}
|
||||||
|
for (const child of children) {
|
||||||
|
const b = buildNode(child, depth + 1);
|
||||||
|
if (b) childBlocks.push(b);
|
||||||
|
}
|
||||||
|
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
||||||
|
|
||||||
|
const childRows = childBlocks.flatMap((b) => b.rows);
|
||||||
|
const descendantLeaves = childRows.filter((r) => !isSubtotal(r));
|
||||||
|
const subtotal = makeSubtotal(cat, descendantLeaves, cat.parent_id ?? null, depth);
|
||||||
|
return { rows: [subtotal, ...childRows], sortKey: sortKey(subtotal) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
|
||||||
|
const rootBlocks: Block[] = [];
|
||||||
|
for (const c of categories) {
|
||||||
|
if (!relevant.has(c.id)) continue;
|
||||||
|
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
|
||||||
|
const b = buildNode(c, 0);
|
||||||
|
if (b) rootBlocks.push(b);
|
||||||
|
}
|
||||||
|
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
||||||
|
|
||||||
|
const rows = rootBlocks.flatMap((b) => b.rows);
|
||||||
|
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
|
||||||
|
for (const l of orphans) rows.push(makeOrphan(l));
|
||||||
|
|
||||||
|
// Stable sort by section so types (income → expense → transfer) are
|
||||||
|
// contiguous; magnitude/tree order within a type is preserved via the index.
|
||||||
|
const order = new Map<T, number>();
|
||||||
|
rows.forEach((r, i) => order.set(r, i));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const ta = sectionOrder[sectionOf(a)] ?? unknownSectionRank;
|
||||||
|
const tb = sectionOrder[sectionOf(b)] ?? unknownSectionRank;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return order.get(a)! - order.get(b)!;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared aggregation for the real-vs-real compare (both MoM and YoY drive the
|
||||||
|
* exact same query — only the eight date bounds differ). Four date buckets
|
||||||
|
* ($1..$8): monthly current/previous and cumulative current/previous.
|
||||||
|
*
|
||||||
|
* This report is an INCOME STATEMENT ("analyse de résultat", Issue #253), not a
|
||||||
|
* pure expense report: revenues are surfaced alongside expenses so the compare
|
||||||
|
* can show a result (revenues − expenses). Per-category sign convention:
|
||||||
|
* - `income` and `transfer` categories use a signed SUM(t.amount). Income
|
||||||
|
* credits stay positive; a `transfer` debit/credit pair (e.g. "Paiement CC":
|
||||||
|
* -500 out, +500 in) nets to ~0 (Issue #243 — a money move, not spending).
|
||||||
|
* - Every other type keeps the expense-report behavior: only outflows
|
||||||
|
* (amount < 0) count, summed as positive magnitudes via ABS.
|
||||||
|
*
|
||||||
|
* The WHERE admits outflows OR any income/transfer row, so income and transfer
|
||||||
|
* *credits* survive the `amount < 0` filter — without it a pure-income category
|
||||||
|
* would be dropped entirely and a transfer could never net to zero. Expense
|
||||||
|
* rows still contribute only their outflows, byte-identical to before. The two
|
||||||
|
* result lines (before/after transfers) are computed in ComparePeriodTable from
|
||||||
|
* 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 accountPlaceholders = inPlaceholders(accountIds, 9);
|
||||||
|
const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
|
||||||
|
return `SELECT
|
||||||
|
t.category_id,
|
||||||
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
|
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))
|
||||||
|
AND (
|
||||||
|
(t.date >= $1 AND t.date <= $2)
|
||||||
|
OR (t.date >= $3 AND t.date <= $4)
|
||||||
|
OR (t.date >= $5 AND t.date <= $6)
|
||||||
|
OR (t.date >= $7 AND t.date <= $8)
|
||||||
|
)${accountFilter}
|
||||||
|
GROUP BY t.category_id, category_name, category_color
|
||||||
|
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
|
||||||
* (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative
|
* (Jan→refMonth of refYear vs Jan→prevMonth 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.
|
* this month"). All SQL parameterised. `accountIds` (Issue #273) optionally
|
||||||
|
* 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);
|
||||||
|
|
@ -461,34 +945,22 @@ export async function getCompareMonthOverMonth(
|
||||||
const cumPreviousStart = `${prev.year}-01-01`;
|
const cumPreviousStart = `${prev.year}-01-01`;
|
||||||
const cumPreviousEnd = prevEnd;
|
const cumPreviousEnd = prevEnd;
|
||||||
|
|
||||||
const rows = await db.select<RawDeltaRow[]>(
|
const deltaParams: unknown[] = [
|
||||||
`SELECT
|
curStart, curEnd,
|
||||||
t.category_id,
|
prevStart, prevEnd,
|
||||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
cumCurrentStart, cumCurrentEnd,
|
||||||
COALESCE(c.color, '#9ca3af') AS category_color,
|
cumPreviousStart, cumPreviousEnd,
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total,
|
];
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
|
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
|
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_previous_total
|
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the
|
||||||
FROM transactions t
|
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards
|
||||||
LEFT JOIN categories c ON t.category_id = c.id
|
// under-specified mocks — db.select never returns undefined in production.
|
||||||
WHERE t.amount < 0
|
const [rows, cats] = await Promise.all([
|
||||||
AND (
|
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
|
||||||
(t.date >= $1 AND t.date <= $2)
|
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
||||||
OR (t.date >= $3 AND t.date <= $4)
|
]);
|
||||||
OR (t.date >= $5 AND t.date <= $6)
|
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
||||||
OR (t.date >= $7 AND t.date <= $8)
|
|
||||||
)
|
|
||||||
GROUP BY t.category_id, category_name, category_color
|
|
||||||
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
|
|
||||||
[
|
|
||||||
curStart, curEnd,
|
|
||||||
prevStart, prevEnd,
|
|
||||||
cumCurrentStart, cumCurrentEnd,
|
|
||||||
cumPreviousStart, cumPreviousEnd,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return rowsToDeltas(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -497,11 +969,13 @@ export async function getCompareMonthOverMonth(
|
||||||
* view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the
|
* view (Jan→refMonth of refYear vs Jan→refMonth 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.
|
* reference month. All SQL parameterised. `accountIds` (Issue #273) optionally
|
||||||
|
* 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);
|
||||||
|
|
@ -512,34 +986,20 @@ export async function getCompareYearOverYear(
|
||||||
const cumPreviousStart = `${year - 1}-01-01`;
|
const cumPreviousStart = `${year - 1}-01-01`;
|
||||||
const cumPreviousEnd = prevMonthEnd;
|
const cumPreviousEnd = prevMonthEnd;
|
||||||
|
|
||||||
const rows = await db.select<RawDeltaRow[]>(
|
const deltaParams: unknown[] = [
|
||||||
`SELECT
|
curMonthStart, curMonthEnd,
|
||||||
t.category_id,
|
prevMonthStart, prevMonthEnd,
|
||||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
cumCurrentStart, cumCurrentEnd,
|
||||||
COALESCE(c.color, '#9ca3af') AS category_color,
|
cumPreviousStart, cumPreviousEnd,
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total,
|
];
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
|
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
|
|
||||||
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_previous_total
|
// See getCompareMonthOverMonth: delta select first, categories alongside.
|
||||||
FROM transactions t
|
const [rows, cats] = await Promise.all([
|
||||||
LEFT JOIN categories c ON t.category_id = c.id
|
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
|
||||||
WHERE t.amount < 0
|
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
||||||
AND (
|
]);
|
||||||
(t.date >= $1 AND t.date <= $2)
|
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
||||||
OR (t.date >= $3 AND t.date <= $4)
|
|
||||||
OR (t.date >= $5 AND t.date <= $6)
|
|
||||||
OR (t.date >= $7 AND t.date <= $8)
|
|
||||||
)
|
|
||||||
GROUP BY t.category_id, category_name, category_color
|
|
||||||
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
|
|
||||||
[
|
|
||||||
curMonthStart, curMonthEnd,
|
|
||||||
prevMonthStart, prevMonthEnd,
|
|
||||||
cumCurrentStart, cumCurrentEnd,
|
|
||||||
cumPreviousStart, cumPreviousEnd,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return rowsToDeltas(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Category zoom (Issue #74) ---
|
// --- Category zoom (Issue #74) ---
|
||||||
|
|
@ -726,18 +1186,25 @@ interface RawMonthFlow {
|
||||||
async function fetchMonthlyFlows(
|
async function fetchMonthlyFlows(
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
dateTo: string,
|
dateTo: string,
|
||||||
|
accountIds?: number[],
|
||||||
): Promise<RawMonthFlow[]> {
|
): Promise<RawMonthFlow[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const params: unknown[] = [dateFrom, dateTo];
|
||||||
|
const accountPlaceholders = inPlaceholders(accountIds, 3);
|
||||||
|
const accountClause = accountPlaceholders
|
||||||
|
? ` AND source_id IN (${accountPlaceholders})`
|
||||||
|
: "";
|
||||||
|
if (accountPlaceholders) params.push(...accountIds!);
|
||||||
return db.select<RawMonthFlow[]>(
|
return db.select<RawMonthFlow[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
strftime('%Y-%m', date) AS month,
|
strftime('%Y-%m', date) AS month,
|
||||||
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
|
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
|
||||||
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
|
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE date >= $1 AND date <= $2
|
WHERE date >= $1 AND date <= $2${accountClause}
|
||||||
GROUP BY month
|
GROUP BY month
|
||||||
ORDER BY month ASC`,
|
ORDER BY month ASC`,
|
||||||
[dateFrom, dateTo],
|
params,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -750,9 +1217,16 @@ async function fetchSeasonality(
|
||||||
month: number,
|
month: number,
|
||||||
yearFrom: number,
|
yearFrom: number,
|
||||||
yearTo: number,
|
yearTo: number,
|
||||||
|
accountIds?: number[],
|
||||||
): Promise<RawSeasonalityRow[]> {
|
): Promise<RawSeasonalityRow[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const mm = String(month).padStart(2, "0");
|
const mm = String(month).padStart(2, "0");
|
||||||
|
const params: unknown[] = [mm, yearFrom, yearTo];
|
||||||
|
const accountPlaceholders = inPlaceholders(accountIds, 4);
|
||||||
|
const accountClause = accountPlaceholders
|
||||||
|
? ` AND source_id IN (${accountPlaceholders})`
|
||||||
|
: "";
|
||||||
|
if (accountPlaceholders) params.push(...accountIds!);
|
||||||
return db.select<RawSeasonalityRow[]>(
|
return db.select<RawSeasonalityRow[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
CAST(strftime('%Y', date) AS INTEGER) AS year,
|
CAST(strftime('%Y', date) AS INTEGER) AS year,
|
||||||
|
|
@ -760,10 +1234,10 @@ async function fetchSeasonality(
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE strftime('%m', date) = $1
|
WHERE strftime('%m', date) = $1
|
||||||
AND CAST(strftime('%Y', date) AS INTEGER) >= $2
|
AND CAST(strftime('%Y', date) AS INTEGER) >= $2
|
||||||
AND CAST(strftime('%Y', date) AS INTEGER) <= $3
|
AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause}
|
||||||
GROUP BY year
|
GROUP BY year
|
||||||
ORDER BY year DESC`,
|
ORDER BY year DESC`,
|
||||||
[mm, yearFrom, yearTo],
|
params,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -779,11 +1253,19 @@ 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.
|
||||||
|
|
@ -793,10 +1275,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),
|
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds),
|
||||||
fetchMonthlyFlows(windowStartIso, refEnd),
|
fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
|
||||||
getCompareMonthOverMonth(referenceYear, referenceMonth),
|
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
|
||||||
getBudgetVsActualData(referenceYear, referenceMonth),
|
getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -947,10 +1429,22 @@ export async function getCartesSnapshot(
|
||||||
// 12-month income vs expenses series for the overlay chart.
|
// 12-month income vs expenses series for the overlay chart.
|
||||||
const flow12Months = buildSeries(12);
|
const flow12Months = buildSeries(12);
|
||||||
|
|
||||||
// Top movers: biggest MoM increases / decreases. `momRows` are sorted by
|
// Top movers: biggest MoM increases / decreases. `momRows` now carries the
|
||||||
|
// 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
|
||||||
|
// leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
|
||||||
|
// 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
|
||||||
|
// salary rise must not appear under "biggest increases" in red. Mirror the
|
||||||
|
// expense-only filter ComparePeriodChart uses. The surviving expense leaves
|
||||||
|
// are byte-identical to the pre-#253 flat output. `momRows` are sorted by
|
||||||
// absolute delta already; filter out near-zero noise and split by sign.
|
// absolute delta already; filter out near-zero noise and split by sign.
|
||||||
const significantMovers = momRows.filter(
|
const significantMovers = momRows.filter(
|
||||||
(r) => r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0),
|
(r) =>
|
||||||
|
!r.is_parent &&
|
||||||
|
(r.category_type ?? "expense") === "expense" &&
|
||||||
|
r.deltaAbs !== 0 &&
|
||||||
|
(r.previousAmount > 0 || r.currentAmount > 0),
|
||||||
);
|
);
|
||||||
// Project the richer CategoryDelta shape down to the narrower CartesTopMover
|
// Project the richer CategoryDelta shape down to the narrower CartesTopMover
|
||||||
// shape so the Cartes dashboard keeps its stable contract regardless of how
|
// shape so the Cartes dashboard keeps its stable contract regardless of how
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,17 @@ 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;
|
||||||
|
|
@ -308,6 +319,16 @@ export interface CategoryDelta {
|
||||||
cumulativeCurrentAmount: number;
|
cumulativeCurrentAmount: number;
|
||||||
cumulativeDeltaAbs: number;
|
cumulativeDeltaAbs: number;
|
||||||
cumulativeDeltaPct: number | null;
|
cumulativeDeltaPct: number | null;
|
||||||
|
// Hierarchy block (Issue #247) — populated ONLY by the real-vs-real Compare
|
||||||
|
// tree builder (getCompareMonthOverMonth / getCompareYearOverYear). Flat
|
||||||
|
// consumers (Highlights movers, Cartes top movers) leave these undefined and
|
||||||
|
// must not read them. Snake_case (unlike the camelCase value fields above) to
|
||||||
|
// mirror BudgetVsActualRow and stay compatible with the shared `reorderRows`
|
||||||
|
// util. `is_parent` rows are subtotals whose value = the net of their group.
|
||||||
|
parent_id?: number | null;
|
||||||
|
is_parent?: boolean;
|
||||||
|
depth?: number;
|
||||||
|
category_type?: "expense" | "income" | "transfer";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Historical alias — used by the highlights hub. Shape identical to CategoryDelta.
|
// Historical alias — used by the highlights hub. Shape identical to CategoryDelta.
|
||||||
|
|
@ -357,11 +378,45 @@ 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[];
|
||||||
colors: Record<string, string>;
|
colors: Record<string, string>;
|
||||||
categoryIds: Record<string, number | null>;
|
categoryIds: Record<string, number | null>;
|
||||||
|
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
|
||||||
|
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 {
|
||||||
|
|
|
||||||
116
src/utils/collapsibleRows.test.ts
Normal file
116
src/utils/collapsibleRows.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
src/utils/collapsibleRows.ts
Normal file
86
src/utils/collapsibleRows.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* 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]);
|
||||||
|
}
|
||||||
123
src/utils/csvAutoDetect.test.ts
Normal file
123
src/utils/csvAutoDetect.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// csvAutoDetect — holdings-CSV detection tests (Issue #245).
|
||||||
|
//
|
||||||
|
// Covers `autoDetectHoldingColumns` (column detection) and `analyzeHoldingsCsv`
|
||||||
|
// (delimiter/header + full analysis). These helpers are pure and DOM-free, in
|
||||||
|
// line with the project's "test the extracted pure pieces" convention. The
|
||||||
|
// numeric parsing + duplicate-merge path lives in `holdingsFromCsvRows`
|
||||||
|
// (tested in useSnapshotEditor.test.ts).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
autoDetectHoldingColumns,
|
||||||
|
analyzeHoldingsCsv,
|
||||||
|
} from "./csvAutoDetect";
|
||||||
|
|
||||||
|
describe("autoDetectHoldingColumns (#245)", () => {
|
||||||
|
it("detects symbol/quantity/price/book_cost from EN headers", () => {
|
||||||
|
const data = [
|
||||||
|
["Symbol", "Quantity", "Price", "Book Cost"],
|
||||||
|
["AAPL", "10", "150.25", "1200.00"],
|
||||||
|
["MSFT", "5", "300.50", "1400.00"],
|
||||||
|
];
|
||||||
|
expect(autoDetectHoldingColumns(data, true)).toEqual({
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 2,
|
||||||
|
book_cost: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unit_price + book_cost null when the CSV has no such columns", () => {
|
||||||
|
const data = [
|
||||||
|
["Symbol", "Shares"],
|
||||||
|
["AAPL", "10"],
|
||||||
|
["MSFT", "5"],
|
||||||
|
];
|
||||||
|
const m = autoDetectHoldingColumns(data, true)!;
|
||||||
|
expect(m.symbol).toBe(0);
|
||||||
|
expect(m.quantity).toBe(1);
|
||||||
|
expect(m.unit_price).toBeNull();
|
||||||
|
expect(m.book_cost).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never maps a Value (qty×price) column to price or book_cost", () => {
|
||||||
|
const data = [
|
||||||
|
["Symbol", "Quantity", "Price", "Value"],
|
||||||
|
["AAPL", "10", "150.25", "1502.50"],
|
||||||
|
["MSFT", "4", "300.50", "1202.00"],
|
||||||
|
];
|
||||||
|
const m = autoDetectHoldingColumns(data, true)!;
|
||||||
|
expect(m.symbol).toBe(0);
|
||||||
|
expect(m.quantity).toBe(1);
|
||||||
|
expect(m.unit_price).toBe(2);
|
||||||
|
// The Value column (col 3) must NOT be picked up as the cost basis.
|
||||||
|
expect(m.book_cost).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects headerless CSVs positionally (symbol / int qty / decimal price)", () => {
|
||||||
|
const data = [
|
||||||
|
["AAPL", "10", "150.25"],
|
||||||
|
["MSFT", "5", "300.50"],
|
||||||
|
["GOOG", "2", "140.10"],
|
||||||
|
];
|
||||||
|
const m = autoDetectHoldingColumns(data, false)!;
|
||||||
|
expect(m.symbol).toBe(0);
|
||||||
|
expect(m.quantity).toBe(1);
|
||||||
|
expect(m.unit_price).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there are no data rows", () => {
|
||||||
|
expect(autoDetectHoldingColumns([], true)).toBeNull();
|
||||||
|
expect(autoDetectHoldingColumns([["Symbol", "Qty"]], true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analyzeHoldingsCsv (#245)", () => {
|
||||||
|
it("parses an EN comma CSV with a header row", () => {
|
||||||
|
const csv = "Symbol,Quantity,Price\nAAPL,10,150.25\nMSFT,5,300.50\n";
|
||||||
|
const a = analyzeHoldingsCsv(csv)!;
|
||||||
|
expect(a.delimiter).toBe(",");
|
||||||
|
expect(a.hasHeader).toBe(true);
|
||||||
|
expect(a.headers).toEqual(["Symbol", "Quantity", "Price"]);
|
||||||
|
expect(a.rows).toEqual([
|
||||||
|
["AAPL", "10", "150.25"],
|
||||||
|
["MSFT", "5", "300.50"],
|
||||||
|
]);
|
||||||
|
expect(a.mapping).toEqual({
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 2,
|
||||||
|
book_cost: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles FR headers (accents), a semicolon delimiter and FR numbers", () => {
|
||||||
|
const csv =
|
||||||
|
"Symbole;Quantité;Cours;Coût\nAAPL;10;150,25;1 200,00\nMSFT;5;300,50;1 400,00\n";
|
||||||
|
const a = analyzeHoldingsCsv(csv)!;
|
||||||
|
expect(a.delimiter).toBe(";");
|
||||||
|
expect(a.hasHeader).toBe(true);
|
||||||
|
expect(a.mapping).toEqual({
|
||||||
|
symbol: 0,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 2,
|
||||||
|
book_cost: 3,
|
||||||
|
});
|
||||||
|
expect(a.rows[0]).toEqual(["AAPL", "10", "150,25", "1 200,00"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns generated headers + all rows for a headerless CSV", () => {
|
||||||
|
const csv = "AAPL,10,150.25\nMSFT,5,300.50\nGOOG,2,140.10\n";
|
||||||
|
const a = analyzeHoldingsCsv(csv)!;
|
||||||
|
expect(a.hasHeader).toBe(false);
|
||||||
|
expect(a.headers).toEqual(["Col 0", "Col 1", "Col 2"]);
|
||||||
|
expect(a.rows).toHaveLength(3);
|
||||||
|
expect(a.mapping.symbol).toBe(0);
|
||||||
|
expect(a.mapping.quantity).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on empty / whitespace-only content", () => {
|
||||||
|
expect(analyzeHoldingsCsv("")).toBeNull();
|
||||||
|
expect(analyzeHoldingsCsv(" \n \n")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -556,3 +556,301 @@ function isSparseComplementary(
|
||||||
|
|
||||||
return total > 0 && complementary / total >= 0.7;
|
return total > 0 && complementary / total >= 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Holdings CSV detection (Issue #245) — a detailed balance account can import a
|
||||||
|
// CSV of positions instead of typing them one by one. This is a SEPARATE flow
|
||||||
|
// from the transaction import above: `autoDetectConfig` is coupled to the
|
||||||
|
// date/amount transaction model, whereas a holdings CSV maps to
|
||||||
|
// symbol / quantity / unit_price / book_cost. Price + book_cost are OPTIONAL
|
||||||
|
// (flexible price detection): when the CSV has no price column the mapping
|
||||||
|
// leaves it `null` and the holdings import without a price (the user fetches or
|
||||||
|
// types it afterwards). Detection is best-effort and the UI lets the user
|
||||||
|
// adjust every column, so an imperfect guess is always recoverable.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface HoldingColumnMapping {
|
||||||
|
/** Column index of the security symbol/ticker (required). */
|
||||||
|
symbol: number;
|
||||||
|
/** Column index of the quantity held (required). */
|
||||||
|
quantity: number;
|
||||||
|
/** Column index of the unit price, or null when the CSV has no price column. */
|
||||||
|
unit_price: number | null;
|
||||||
|
/** Column index of the acquisition cost basis, or null when absent. */
|
||||||
|
book_cost: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoldingCsvAnalysis {
|
||||||
|
delimiter: string;
|
||||||
|
hasHeader: boolean;
|
||||||
|
/** Header labels (actual cells when `hasHeader`, else `Col 0`, `Col 1`, …). */
|
||||||
|
headers: string[];
|
||||||
|
/** DATA rows only (the header row, if any, is stripped). */
|
||||||
|
rows: string[][];
|
||||||
|
mapping: HoldingColumnMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header keyword sets, matched against accent-stripped, alphanumeric-only
|
||||||
|
// header cells. Bilingual (FR default + EN). Order inside each list is priority
|
||||||
|
// order for the primary-keyword pass.
|
||||||
|
const SYMBOL_HEADER_KEYWORDS = ["symbol", "symbole", "ticker", "titre"];
|
||||||
|
const QUANTITY_HEADER_KEYWORDS = [
|
||||||
|
"quantity",
|
||||||
|
"quantite",
|
||||||
|
"qty",
|
||||||
|
"qte",
|
||||||
|
"shares",
|
||||||
|
"actions",
|
||||||
|
"parts",
|
||||||
|
"unites",
|
||||||
|
"units",
|
||||||
|
"nombre",
|
||||||
|
];
|
||||||
|
const PRICE_HEADER_KEYWORDS = [
|
||||||
|
"prixunitaire",
|
||||||
|
"unitprice",
|
||||||
|
"marketprice",
|
||||||
|
"price",
|
||||||
|
"prix",
|
||||||
|
"cours",
|
||||||
|
"cotation",
|
||||||
|
"cloture",
|
||||||
|
"close",
|
||||||
|
];
|
||||||
|
const BOOKCOST_HEADER_KEYWORDS = [
|
||||||
|
"bookcost",
|
||||||
|
"costbasis",
|
||||||
|
"prixderevient",
|
||||||
|
"acquisition",
|
||||||
|
"revient",
|
||||||
|
"cost",
|
||||||
|
"cout",
|
||||||
|
];
|
||||||
|
// Value/market-value columns must never be auto-picked as price or book_cost.
|
||||||
|
const VALUE_HEADER_KEYWORDS = ["value", "valeur", "montant", "marchande"];
|
||||||
|
|
||||||
|
/** Accent-strip + lowercase + keep alphanumerics only (for header matching). */
|
||||||
|
function normalizeHeaderCell(s: string): string {
|
||||||
|
return (s ?? "")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first unused column whose normalized header contains one of the
|
||||||
|
* keywords (keywords tried in priority order). `exclude` skips columns whose
|
||||||
|
* header contains any excluded token (e.g. a "value" column for price).
|
||||||
|
*/
|
||||||
|
function matchHeaderColumn(
|
||||||
|
normalizedHeaders: string[],
|
||||||
|
keywords: string[],
|
||||||
|
used: Set<number>,
|
||||||
|
exclude: string[] = []
|
||||||
|
): number | null {
|
||||||
|
for (const kw of keywords) {
|
||||||
|
for (let i = 0; i < normalizedHeaders.length; i++) {
|
||||||
|
if (used.has(i)) continue;
|
||||||
|
const h = normalizedHeaders[i];
|
||||||
|
if (!h) continue;
|
||||||
|
if (exclude.some((ex) => h.includes(ex))) continue;
|
||||||
|
if (h.includes(kw)) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the shortest-average-length non-numeric, unused text column (symbols
|
||||||
|
* are short tokens; a name/description column is longer). */
|
||||||
|
function pickSymbolColumn(
|
||||||
|
rows: string[][],
|
||||||
|
colCount: number,
|
||||||
|
numericCols: Set<number>,
|
||||||
|
used: Set<number>
|
||||||
|
): number | null {
|
||||||
|
let best: number | null = null;
|
||||||
|
let bestAvg = Infinity;
|
||||||
|
for (let col = 0; col < colCount; col++) {
|
||||||
|
if (used.has(col) || numericCols.has(col)) continue;
|
||||||
|
let totalLen = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const cell = row[col]?.trim();
|
||||||
|
if (!cell) continue;
|
||||||
|
totalLen += cell.length;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count === 0) continue;
|
||||||
|
const avg = totalLen / count;
|
||||||
|
if (avg < bestAvg) {
|
||||||
|
bestAvg = avg;
|
||||||
|
best = col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick an unused numeric column, optionally preferring integer-heavy (quantity)
|
||||||
|
* or decimal-heavy (price) columns. Returns null when none remain. */
|
||||||
|
function pickNumericColumn(
|
||||||
|
rows: string[][],
|
||||||
|
numericCols: number[],
|
||||||
|
used: Set<number>,
|
||||||
|
opts: { preferIntegers?: boolean; preferDecimals?: boolean }
|
||||||
|
): number | null {
|
||||||
|
let best: number | null = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const col of numericCols) {
|
||||||
|
if (used.has(col)) continue;
|
||||||
|
let ints = 0;
|
||||||
|
let decimals = 0;
|
||||||
|
let nonEmpty = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const cell = row[col]?.trim();
|
||||||
|
if (!cell) continue;
|
||||||
|
const v = parseFrenchAmount(cell);
|
||||||
|
if (isNaN(v)) continue;
|
||||||
|
nonEmpty++;
|
||||||
|
if (Number.isInteger(v)) ints++;
|
||||||
|
else decimals++;
|
||||||
|
}
|
||||||
|
if (nonEmpty === 0) continue;
|
||||||
|
let score = 1;
|
||||||
|
if (opts.preferIntegers) score = ints / nonEmpty;
|
||||||
|
else if (opts.preferDecimals) score = decimals / nonEmpty;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the symbol / quantity / unit_price / book_cost columns of a holdings
|
||||||
|
* CSV. `data` is the parsed 2-D array INCLUDING the header row when
|
||||||
|
* `hasHeader`. Returns a best-effort mapping (symbol + quantity always set so
|
||||||
|
* the editor can render), or null only when there is no usable data.
|
||||||
|
* Exported for unit tests.
|
||||||
|
*/
|
||||||
|
export function autoDetectHoldingColumns(
|
||||||
|
data: string[][],
|
||||||
|
hasHeader: boolean
|
||||||
|
): HoldingColumnMapping | null {
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
const colCount = Math.max(...data.map((r) => r.length));
|
||||||
|
if (colCount === 0) return null;
|
||||||
|
const dataRows = hasHeader ? data.slice(1) : data;
|
||||||
|
if (dataRows.length === 0) return null;
|
||||||
|
|
||||||
|
const used = new Set<number>();
|
||||||
|
let symbol: number | null = null;
|
||||||
|
let quantity: number | null = null;
|
||||||
|
let unitPrice: number | null = null;
|
||||||
|
let bookCost: number | null = null;
|
||||||
|
|
||||||
|
// Step 1 — header keyword matching (most reliable when present).
|
||||||
|
if (hasHeader) {
|
||||||
|
const normalized = data[0].map(normalizeHeaderCell);
|
||||||
|
symbol = matchHeaderColumn(normalized, SYMBOL_HEADER_KEYWORDS, used);
|
||||||
|
if (symbol !== null) used.add(symbol);
|
||||||
|
quantity = matchHeaderColumn(normalized, QUANTITY_HEADER_KEYWORDS, used);
|
||||||
|
if (quantity !== null) used.add(quantity);
|
||||||
|
unitPrice = matchHeaderColumn(
|
||||||
|
normalized,
|
||||||
|
PRICE_HEADER_KEYWORDS,
|
||||||
|
used,
|
||||||
|
VALUE_HEADER_KEYWORDS
|
||||||
|
);
|
||||||
|
if (unitPrice !== null) used.add(unitPrice);
|
||||||
|
bookCost = matchHeaderColumn(
|
||||||
|
normalized,
|
||||||
|
BOOKCOST_HEADER_KEYWORDS,
|
||||||
|
used,
|
||||||
|
VALUE_HEADER_KEYWORDS
|
||||||
|
);
|
||||||
|
if (bookCost !== null) used.add(bookCost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value / market-value column must never be auto-assigned to a numeric
|
||||||
|
// target by the heuristics below (it is qty × price, not a source column).
|
||||||
|
// Mark such columns used up-front so the fallback pickers skip them.
|
||||||
|
if (hasHeader) {
|
||||||
|
const normalized = data[0].map(normalizeHeaderCell);
|
||||||
|
normalized.forEach((h, i) => {
|
||||||
|
if (!used.has(i) && VALUE_HEADER_KEYWORDS.some((v) => h && h.includes(v))) {
|
||||||
|
used.add(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — numeric/text heuristics fill any column the header didn't resolve.
|
||||||
|
const sampleRows = dataRows.slice(0, 20);
|
||||||
|
const numericCols = detectNumericColumns(sampleRows, colCount);
|
||||||
|
const numericSet = new Set(numericCols);
|
||||||
|
|
||||||
|
if (symbol === null) {
|
||||||
|
symbol = pickSymbolColumn(sampleRows, colCount, numericSet, used);
|
||||||
|
if (symbol !== null) used.add(symbol);
|
||||||
|
}
|
||||||
|
if (quantity === null) {
|
||||||
|
quantity = pickNumericColumn(sampleRows, numericCols, used, {
|
||||||
|
preferIntegers: true,
|
||||||
|
});
|
||||||
|
if (quantity !== null) used.add(quantity);
|
||||||
|
}
|
||||||
|
if (unitPrice === null) {
|
||||||
|
unitPrice = pickNumericColumn(sampleRows, numericCols, used, {
|
||||||
|
preferDecimals: true,
|
||||||
|
});
|
||||||
|
if (unitPrice !== null) used.add(unitPrice);
|
||||||
|
}
|
||||||
|
if (bookCost === null) {
|
||||||
|
bookCost = pickNumericColumn(sampleRows, numericCols, used, {});
|
||||||
|
if (bookCost !== null) used.add(bookCost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbol + quantity are required; fall back to sane defaults so the editor
|
||||||
|
// always has something to show (the user can correct it).
|
||||||
|
if (symbol === null) symbol = 0;
|
||||||
|
if (quantity === null) quantity = symbol === 0 && colCount > 1 ? 1 : 0;
|
||||||
|
|
||||||
|
return { symbol, quantity, unit_price: unitPrice, book_cost: bookCost };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full holdings-CSV analysis: preprocess quoted lines, detect the delimiter and
|
||||||
|
* header, then the column mapping. Returns the header labels + DATA rows (header
|
||||||
|
* stripped) so the caller can render a mapping editor + preview and feed the
|
||||||
|
* rows to `holdingsFromCsvRows`. Null when the content has no usable rows.
|
||||||
|
* Exported for unit tests.
|
||||||
|
*/
|
||||||
|
export function analyzeHoldingsCsv(
|
||||||
|
rawContent: string
|
||||||
|
): HoldingCsvAnalysis | null {
|
||||||
|
const content = preprocessQuotedCSV(rawContent);
|
||||||
|
const nonEmptyLines = content.split(/\r?\n/).filter((l) => l.trim());
|
||||||
|
if (nonEmptyLines.length === 0) return null;
|
||||||
|
|
||||||
|
const delimiter = detectDelimiter(nonEmptyLines.slice(0, 10));
|
||||||
|
if (!delimiter) return null;
|
||||||
|
|
||||||
|
const parsed = Papa.parse(content, { delimiter, skipEmptyLines: true });
|
||||||
|
const data = (parsed.data as string[][]).filter((r) =>
|
||||||
|
r.some((c) => (c ?? "").trim() !== "")
|
||||||
|
);
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
|
const hasHeader = detectHeader(data[0]);
|
||||||
|
const mapping = autoDetectHoldingColumns(data, hasHeader);
|
||||||
|
if (!mapping) return null;
|
||||||
|
|
||||||
|
const colCount = Math.max(...data.map((r) => r.length));
|
||||||
|
const headers = hasHeader
|
||||||
|
? data[0].map((h, i) => (h ?? "").trim() || `Col ${i}`)
|
||||||
|
: Array.from({ length: colCount }, (_, i) => `Col ${i}`);
|
||||||
|
const rows = hasHeader ? data.slice(1) : data;
|
||||||
|
|
||||||
|
return { delimiter, hasHeader, headers, rows, mapping };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
* Shared utility for reordering budget table rows.
|
* Shared utility for reordering budget table rows.
|
||||||
* Recursively moves subtotal (parent) rows below their children
|
* Recursively moves subtotal (parent) rows below their children
|
||||||
* at every depth level when "subtotals on bottom" is enabled.
|
* at every depth level when "subtotals on bottom" is enabled.
|
||||||
|
*
|
||||||
|
* The generic constraint only requires the two fields the algorithm actually
|
||||||
|
* reads (`is_parent`, `depth`) so it works for both the budget rows (required
|
||||||
|
* `is_parent`) and the Compare `CategoryDelta` rows (optional `is_parent`).
|
||||||
*/
|
*/
|
||||||
export function reorderRows<
|
export function reorderRows<
|
||||||
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number },
|
T extends { is_parent?: boolean; depth?: number },
|
||||||
>(rows: T[], subtotalsOnTop: boolean): T[] {
|
>(rows: T[], subtotalsOnTop: boolean): T[] {
|
||||||
if (subtotalsOnTop) return rows;
|
if (subtotalsOnTop) return rows;
|
||||||
|
|
||||||
|
|
|
||||||
27
src/utils/sqlFilters.test.ts
Normal file
27
src/utils/sqlFilters.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/utils/sqlFilters.ts
Normal file
16
src/utils/sqlFilters.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* 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(", ");
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue