Compare commits
No commits in common. "main" and "issue-187-balance-cleanup-post-184-185" have entirely different histories.
main
...
issue-187-
113 changed files with 2189 additions and 16921 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-07-01
|
updated: 2026-04-19
|
||||||
---
|
---
|
||||||
|
|
||||||
# /release — Release Simpl-Resultat
|
# /release — Release Simpl-Resultat
|
||||||
|
|
@ -24,9 +24,9 @@ updated: 2026-07-01
|
||||||
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
|
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
|
||||||
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
|
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
|
||||||
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
|
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
|
||||||
- `CHANGELOG.md` (EN) : header `## [Unreleased]`
|
- `CHANGELOG.md` (EN)
|
||||||
- `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide)
|
- `CHANGELOG.fr.md` (FR)
|
||||||
- Pattern de migration : transformer le header « non publié » de chaque fichier en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer la section « non publié » vide au-dessus** pour accueillir les prochaines entrees. Le header differe selon la langue. Ne pas deplacer le contenu — les sections sont laissees en place.
|
- Pattern de migration : transformer `## [Unreleased]` en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer une section `## [Unreleased]` vide au-dessus** pour accueillir les prochaines entrees. Ne pas deplacer le contenu — les sections sont laissees en place.
|
||||||
4. Si changement d'architecture : mettre a jour `docs/architecture.md`
|
4. Si changement d'architecture : mettre a jour `docs/architecture.md`
|
||||||
5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs)
|
5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs)
|
||||||
6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) :
|
6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) :
|
||||||
|
|
@ -48,4 +48,3 @@ updated: 2026-07-01
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)
|
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)
|
||||||
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.
|
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -63,9 +63,8 @@ src-tauri/icons/ios/
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
decisions-log.md
|
decisions-log.md
|
||||||
|
|
||||||
# Autopilot scratch + daily reports (repo root only — must not match
|
# Autopilot scratch + daily reports
|
||||||
# src/components/reports/, which is real tracked source)
|
reports/
|
||||||
/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,98 +2,12 @@
|
||||||
|
|
||||||
## [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
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
|
|
||||||
- Bilan : correction d'une erreur **« database is locked »** qui pouvait apparaître après avoir commencé un snapshot puis quitté la page avant d'enregistrer, bloquant ensuite toute écriture jusqu'au redémarrage de l'app. Tout l'accès à la base est désormais sérialisé, et une transaction tient une seule connexion sur toute sa durée — une transaction d'écriture ne peut donc plus rester bloquée entre deux connexions SQLite (la cause racine). Aucune donnée enregistrée n'a été menacée ; seul le snapshot en cours était perdu (#230).
|
|
||||||
- La console de journaux intégrée (Paramètres → Systèmes → Journaux) se met maintenant à jour **en direct** à mesure que les événements surviennent, au lieu de n'afficher que les entrées présentes avant l'ouverture de la page. Les enregistrements de snapshot et leurs échecs sont désormais journalisés, donc les problèmes de base de données y apparaissent (#230).
|
|
||||||
|
|
||||||
## [0.10.0] - 2026-06-29
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
|
|
||||||
- Bilan : **saisie de snapshot par titre**. Un compte *détaillé* se saisit désormais titre par titre — chaque position a sa propre ligne avec un sélecteur de titre (autocomplétion sur vos titres existants, avec création inline d'un nouveau symbole), quantité, cours (avec la récupération automatique de prix optionnelle), coût d'acquisition, et un gain latent calculé en direct. La valeur du compte est la somme affichée de ses positions. Les comptes simples sont inchangés. Le sélecteur de titre accepte n'importe quel symbole normalisé (MAJUSCULES/sans espaces) — pas de validation en direct du symbole, puisque la récupération du prix est une étape séparée et au mieux ; vous choisissez la classe d'actif (Action / Crypto) à la création d'un nouveau symbole (#214).
|
|
||||||
- Bilan : **détailler un compte agrégé en titres** (assistant). Un compte *simple* peut être basculé en *détaillé* via l'action « Détailler en titres » dans le tableau des comptes. La bascule est à sens unique : elle passe le compte en détaillé et fixe la date du jour comme date de bascule (pivot, `detailed_since`) — l'historique agrégé passé reste figé en lecture seule, et les titres se saisissent au prochain snapshot normal (l'assistant ne capture pas de portefeuille initial). Une fois un titre saisi, le compte ne peut plus revenir en saisie simple (verrouillé à la fois dans l'interface et dans le service) (#215).
|
|
||||||
- Bilan : **drill-down par titre et gain latent dans le tableau des comptes et l'aperçu**. Un compte détaillé peut être déplié pour afficher ses titres (valeur + gain latent %), et le gain latent (valeur − coût d'acquisition) est agrégé par compte, par classe d'actif et par enveloppe fiscale dans le tableau des comptes et la carte d'aperçu du bilan. Les positions sans coût d'acquisition sont signalées « N/A » et exclues du pourcentage (pas de division par zéro) ; le rendement Modified Dietz par compte est inchangé (#216).
|
|
||||||
- Bilan : la date d'un snapshot existant peut maintenant être déplacée. Le champ date devient modifiable en mode édition — changez-la puis enregistrez, et le snapshot (avec toutes ses lignes) est déplacé à la nouvelle date dans une seule transaction atomique. Si un autre snapshot occupe déjà la date cible, le déplacement est refusé avec un message clair et rien n'est modifié (#200).
|
|
||||||
- Bilan : **enveloppe fiscale sur les comptes**. Un compte peut désormais porter une enveloppe fiscale optionnelle (Non-enregistré, CELI, REER, FERR, CELIAPP, REEE — ou aucune), choisie via un menu déroulant dans le formulaire de compte. C'est un axe distinct du type du compte (la classe d'actif) : un compte d'actions logé dans un CELI est enfin exprimable — type *Actions* + enveloppe *CELI*. La migration v12 ajoute la colonne nullable `balance_accounts.vehicle_type` avec un CHECK sur l'enum et backfille les anciens comptes CELI/REER (#202, #203).
|
|
||||||
- Bilan : **axe enveloppe du graphique empilé**. Le graphique d'évolution empilé gagne un sous-choix d'axe — **Par classe d'actif** (défaut, comportement inchangé) ou **Par enveloppe** (regroupe par enveloppe fiscale, avec un bucket « Aucune » pour les comptes sans enveloppe). Lisez votre patrimoine par ce que vous détenez *ou* par l'abri fiscal où il se trouve (#204).
|
|
||||||
- Bilan : **colonnes de rendement repliables**. Les quatre colonnes de rendement Modified Dietz du tableau des comptes (3M / 1A / depuis création / non-ajusté) sont maintenant repliées par défaut derrière un bouton afficher/masquer ; le choix est mémorisé d'une session à l'autre (`user_preferences.balance_show_returns`) pour réduire le bruit visuel chez les utilisateurs qui ne suivent que des valeurs (#204).
|
|
||||||
|
|
||||||
### Modifié
|
|
||||||
|
|
||||||
- Bilan : terminologie clarifiée. Les regroupements de comptes (Liquidités, CELI, REER, etc.) sont désormais appelés **types** de façon cohérente dans l'interface du bilan et le guide utilisateur, pour éviter la confusion avec les *catégories* de transactions (un autre module). Le type « Encaisse » devient **Liquidités** et « Fonds commun » devient **Fonds / FNB**. Le terme *snapshot* est conservé mais glosé à son premier usage (#198).
|
|
||||||
- Bilan : **un type est désormais une pure classe d'actif**. Les types standard sont les cinq classes d'actif — Liquidités, Fonds / FNB, Actions, Crypto, Autres. Les enveloppes fiscales qui étaient des types (CELI, REER) ne sont plus des types : elles ont migré vers le nouvel attribut d'enveloppe fiscale, porté par le compte. À la migration, les comptes CELI/REER existants sont reclassés dans la classe d'actif **Autres** tout en conservant leur enveloppe (`tfsa`/`rrsp`) ; les types seedés `tfsa`/`rrsp` sont désactivés et disparaissent des menus de types (migrations v12 + v13). Les nouveaux profils seedent cinq classes d'actif ; les comptes de départ CELI/REER passent sous *Autres* avec leur enveloppe renseignée. Vos montants et votre historique ne changent pas — seul le regroupement change (#202).
|
|
||||||
- Bilan : **note sur les bilans historiques**. L'axe « par classe d'actif » du graphique est recalculé sur le type *actuel* de chaque compte ; un snapshot saisi *avant* cette migration pour un ancien compte CELI/REER apparaît donc désormais sous **Autres** sur cet axe (il ne s'affiche plus sous « CELI »/« REER »). C'est attendu — le nouvel axe **par enveloppe** retrouve bien ces CELI/REER, et aucune valeur ni aucun historique n'est modifié (#204).
|
|
||||||
- Bilan : **renommer un type ne casse plus la traduction** (correction de bug). Renommer un type écrasait auparavant sa clé i18n avec le texte libre, cassant la traduction FR/EN. Le nom personnalisé est désormais stocké à part dans `balance_categories.custom_label`, et la clé de traduction d'origine n'est jamais touchée (et réapparaît si vous videz le nom personnalisé). La migration v12 récupère aussi, en défense, tout type seedé dont la clé de traduction aurait déjà été écrasée par un renommage antérieur (#202, #203).
|
|
||||||
- Bilan : **les comptes cotés existants sont convertis automatiquement en comptes détaillés**. À la migration, chaque compte coté existant (doté d'une classe d'actif et d'un symbole) devient un compte *détaillé* à une seule position — ses lignes historiques quantité/cours sont reflétées en positions par titre, sans perte de valeur ni d'historique, et la ligne agrégée conserve le total. Les comptes cotés sans classe d'actif ou sans symbole restent intacts. La valeur du compte et toutes les agrégations sont inchangées ; vous pouvez désormais ajouter d'autres titres au même compte (migrations v14/v15/v16) (#211).
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
|
|
||||||
- Bilan : le symbole (ticker) est maintenant optionnel pour les comptes d'un type coté. Un compte coté peut être créé ou modifié sans symbole — la valorisation manuelle (quantité × prix unitaire) n'en a jamais eu besoin ; un symbole n'est requis que pour utiliser le bouton de récupération automatique des prix (#199).
|
|
||||||
|
|
||||||
## [0.9.1] - 2026-05-10
|
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
||||||
- Bilan : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils, plus un modal d'opt-in unique pour les profils existants à leur première visite de /balance. Cases cochées par défaut ; les comptes existants avec le même nom + catégorie désactivent la ligne correspondante avec un tooltip « Déjà présent ». Confirmation ou ignorance enregistrée dans `user_preferences.balance_starter_proposed` pour que le modal ne réapparaisse jamais. ADR 0012 (Proposed) capture le futur modèle à deux niveaux véhicule × composition (#179).
|
- Bilan : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils, plus un modal d'opt-in unique pour les profils existants à leur première visite de /balance. Cases cochées par défaut ; les comptes existants avec le même nom + catégorie désactivent la ligne correspondante avec un tooltip « Déjà présent ». Confirmation ou ignorance enregistrée dans `user_preferences.balance_starter_proposed` pour que le modal ne réapparaisse jamais. ADR 0012 (Proposed) capture le futur modèle à deux niveaux véhicule × composition (#179).
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
- **Récupération de prix activée** — `/v1/prices` de `maximus-api` est en production depuis le 2026-05-05. La fonctionnalité premium de récupération de prix livrée en 0.9.0 (#160) est désormais fonctionnellement disponible de bout en bout (#161).
|
|
||||||
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
|
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
|
||||||
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
|
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
|
||||||
- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178).
|
- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178).
|
||||||
|
|
@ -119,7 +33,7 @@
|
||||||
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
|
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
|
||||||
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
|
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
|
||||||
|
|
||||||
- **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement (activation serveur en attente — fonctionnalité dormante jusqu'à la mise en ligne de `/v1/prices` de maximus-api). (#160)
|
- **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement. (#160)
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49)
|
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49)
|
||||||
|
|
|
||||||
88
CHANGELOG.md
88
CHANGELOG.md
|
|
@ -2,98 +2,12 @@
|
||||||
|
|
||||||
## [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
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Balance: fixed a **"database is locked"** error that could appear after starting a snapshot and leaving the page before saving, then blocked every later write until the app was restarted. All database access is now serialized, and a transaction holds a single connection for its whole duration — so a write transaction can no longer be stranded across SQLite connections (the root cause). No stored data was ever at risk; only the in-progress snapshot was lost (#230).
|
|
||||||
- The in-app log console (Settings → Systems → Logs) now updates **live** as events happen, instead of only showing entries that existed before the page was opened. Snapshot saves and their failures are now logged, so database issues surface there (#230).
|
|
||||||
|
|
||||||
## [0.10.0] - 2026-06-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Balance: **per-security snapshot entry**. A *detailed* account is now entered title by title — each holding has its own row with a security picker (autocomplete over your existing securities, with inline creation of a new ticker), quantity, price (with the optional automatic price fetch), cost basis, and a live unrealized-gain figure. The account's value is the displayed sum of its positions. Simple accounts are unchanged. The security picker accepts any normalized symbol (UPPER/TRIM) — there is no live ticker validation, since the price fetch is a separate, best-effort step; you choose the asset class (Stock / Crypto) when creating a new symbol (#214).
|
|
||||||
- Balance: **detail an aggregated account into securities** (wizard). A *simple* account can be switched to *detailed* via a "Detail into securities" action in the accounts table. The switch is one-way: it sets the account to detailed and stamps today as the pivot date (`detailed_since`) — past aggregated history stays frozen and read-only, and the individual securities are entered at your next normal snapshot (the wizard does not capture an initial portfolio). Once any holding is entered, the account can no longer revert to simple (enforced both in the UI and in the service) (#215).
|
|
||||||
- Balance: **per-security drill-down and unrealized gain in the accounts table and overview**. A detailed account can be expanded to show its securities (value + unrealized gain %), and the unrealized gain (value − cost basis) is aggregated by account, by asset class, and by fiscal envelope in the accounts table and the balance overview card. Positions without a cost basis are flagged "N/A" and excluded from the percentage (no division by zero); the per-account Modified Dietz return is unchanged (#216).
|
|
||||||
- Balance: an existing snapshot's date can now be moved. The date field is editable in edit mode — change it and save, and the snapshot (with all its lines) is moved to the new date inside a single atomic transaction. If another snapshot already occupies the target date, the move is rejected with a clear message and nothing changes (#200).
|
|
||||||
- Balance: **fiscal envelope on accounts**. An account can now carry an optional fiscal envelope (Non-registered, TFSA, RRSP, RRIF, FHSA, RESP — or none), set via a dropdown in the account form. This is a separate axis from the account's type (asset class), so an account holding stocks inside a TFSA is finally expressible — type *Stocks* + envelope *TFSA*. Migration v12 adds the nullable `balance_accounts.vehicle_type` column with a CHECK on the enum and backfills the former TFSA/RRSP accounts (#202, #203).
|
|
||||||
- Balance: **stacked-chart envelope axis**. The stacked evolution chart gains an axis sub-toggle — **By asset class** (default, unchanged behaviour) or **By envelope** (groups by fiscal envelope, with a "None" bucket for accounts without one). Read your net worth by what you hold *or* by where it's sheltered (#204).
|
|
||||||
- Balance: **collapsible return columns**. The accounts table's four Modified Dietz return columns (3M / 1Y / since inception / unadjusted) are now collapsed by default behind a show/hide toggle; the choice is persisted across sessions (`user_preferences.balance_show_returns`) to reduce visual noise for users who only track values (#204).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Balance: clearer terminology. Account groupings (Cash, TFSA, RRSP, etc.) are now consistently called **types** across the balance UI and user guide, to avoid confusion with transaction *categories* (a separate module). The "Cash" type is now labelled **Cash** (FR: Liquidités) and "Mutual fund" becomes **Funds / ETF** (FR: Fonds / FNB). The wording of *snapshot* is unchanged but glossed on first use (#198).
|
|
||||||
- Balance: **a type is now a pure asset class**. The standard types are the five asset classes — Cash, Funds / ETF, Stocks, Crypto, Other. The fiscal envelopes that used to be types (TFSA, RRSP) are no longer types: they moved to the new per-account fiscal-envelope attribute. On migration, existing TFSA/RRSP accounts are reclassed to the **Other** asset class while carrying their envelope (`tfsa`/`rrsp`); the `tfsa`/`rrsp` seed types are deactivated and disappear from the type dropdowns (migrations v12 + v13). New profiles seed five asset classes; the TFSA/RRSP starter accounts now sit under *Other* with their envelope set. Your amounts and history are unchanged — only the grouping changes (#202).
|
|
||||||
- Balance: **historical reclass note**. The "by asset class" chart axis is recomputed on each account's *current* type, so a snapshot taken *before* this migration for a former TFSA/RRSP account now appears under **Other** on that axis (it no longer shows under "TFSA"/"RRSP"). This is expected — the new **by envelope** axis still surfaces those CELI/REER, and no value or history is altered (#204).
|
|
||||||
- Balance: **renaming a type no longer breaks the translation** (bug fix). Renaming a type used to overwrite its i18n key with the free text, clobbering the FR/EN translation. The custom name is now stored separately in `balance_categories.custom_label`, and the original translation key is never touched (and reappears if you clear the custom name). Migration v12 also defensively recovers any seed type whose translation key had already been clobbered by an earlier rename (#202, #203).
|
|
||||||
- Balance: **existing priced accounts auto-converted to detailed**. On migration, every existing priced account (one with an asset class and a symbol) becomes a *detailed* account with a single position — its historical quantity/price lines are mirrored into per-security holdings with no value or history loss, and the aggregated line keeps the total. Priced accounts without an asset class or without a symbol are left untouched. The account's value and all aggregations are unchanged; you can now add further securities to the same account (migrations v14/v15/v16) (#211).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Balance: the symbol (ticker) is now optional for accounts in a priced type. A priced account can be created or edited without a symbol — manual valuation (quantity × unit price) never needed it; a symbol is only required to use the automatic price-fetch button (#199).
|
|
||||||
|
|
||||||
## [0.9.1] - 2026-05-10
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Bilan: 4 starter accounts (Checking account, TFSA, RRSP, Non-registered account) are seeded for new profiles, and a one-shot opt-in modal proposes them to existing profiles on their first /balance visit. Default-checked checkboxes; existing accounts with the same name + category disable the matching row with a "Already exists" tooltip. Confirming or dismissing both write `user_preferences.balance_starter_proposed` so the modal never re-appears. ADR 0012 (Proposed) captures the future two-level vehicle × composition model (#179).
|
- Bilan: 4 starter accounts (Checking account, TFSA, RRSP, Non-registered account) are seeded for new profiles, and a one-shot opt-in modal proposes them to existing profiles on their first /balance visit. Default-checked checkboxes; existing accounts with the same name + category disable the matching row with a "Already exists" tooltip. Confirming or dismissing both write `user_preferences.balance_starter_proposed` so the modal never re-appears. ADR 0012 (Proposed) captures the future two-level vehicle × composition model (#179).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Price-fetching activated** — `maximus-api` `/v1/prices` is live in production since 2026-05-05. The premium price-fetching feature shipped in 0.9.0 (#160) is now functionally available end-to-end (#161).
|
|
||||||
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
|
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
|
||||||
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
|
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
|
||||||
- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178).
|
- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178).
|
||||||
|
|
@ -119,7 +33,7 @@
|
||||||
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
|
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
|
||||||
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
|
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
|
||||||
|
|
||||||
- **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent (server activation pending — feature dormant until maximus-api `/v1/prices` ships). (#160)
|
- **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent. (#160)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49)
|
- **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49)
|
||||||
|
|
|
||||||
|
|
@ -118,8 +118,8 @@ src-tauri/
|
||||||
|
|
||||||
## Base de données
|
## Base de données
|
||||||
|
|
||||||
- **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index
|
- **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail)
|
||||||
- **16 migrations inline** dans `lib.rs` (v1→v16, via `tauri_plugin_sql::Migration`). Étape 2 (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position). Voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md)
|
- **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
||||||
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
||||||
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
||||||
|
|
||||||
|
|
|
||||||
36
STATE.md
36
STATE.md
|
|
@ -1,35 +1,27 @@
|
||||||
# STATE — Simpl'Résultat
|
# STATE — Simpl'Résultat
|
||||||
|
|
||||||
> Derniere MAJ : 2026-07-12 (**#259 recadrée** après `/analyze` : son corps décrivait un flux inexistant du Bilan, le vrai sujet est le **mapping manuel des catégories custom** en migration de taxonomie → réécrite `status:ready`, **hors epic #260** ; l'epic #260 n'a donc plus de reliquat. Précédemment 2026-07-11 : **M2 « rapports-parite » #277-#279 shippée** sur `main` `a982f9e` — /pr-review ×3 [APPROVE #285/#286, REQUEST_CHANGES #287 → point I7 « filtrage Cartes » confirmé + fix `9ee5ad3`], merge local pile de 3 + union CHANGELOG, tip cumulé validé **811 vitest** + 98 Rust, réconciliation Forgejo complète. **30 commits non taggés depuis `v0.12.0`** — release en attente)
|
> Derniere MAJ : 2026-05-03 (bootstrap initial)
|
||||||
|
|
||||||
## 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).
|
Phase post-Bilan : milestone complet (5 sub-features merged, ADRs 0008-0010+0012). Polish + prep release pour shipper la nouvelle pubkey Ed25519 alignee sur maximus-api LIVE (`api.lacompagniemaximus.com`). Prochains gros chantiers : activation en ligne (#53), pipeline Stripe (#50, #135-136), price-fetching premium production (#161).
|
||||||
|
|
||||||
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-05-03 : postcss 8.5.6 -> 8.5.13, fix GHSA-qx2v-qp2m-jg93 (transitif via vite, build-time only, exposition runtime nulle) (ref #180)
|
||||||
- 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-05-02 : Settings eclate en 3 sous-pages `/settings/{users,data,systems}` + redirections legacy `/docs` et `/changelog` (ref #190)
|
||||||
- 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-05-02 : Doc license — placeholder Bearer JWT-like remplace par `<license-token>` (ref #181)
|
||||||
- 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-05-01 : WebKitGTK date picker — `blur()` apres selection sur `/balance/snapshot` (ref #177)
|
||||||
- 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-05-01 : Icon Tauri custom (calculatrice + cadenas privacy), 16 raster sizes regenerees
|
||||||
- 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-04-29 : Bilan starter accounts (4 comptes seedes + modal opt-in) + ADR 0012 vehicle x composition (ref #179)
|
||||||
- 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-04-29 : Bilan onboarding 2-step empty state `/balance` (ref #178)
|
||||||
- 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-04-28 : Bilan snapshot save atomic BEGIN/COMMIT + migration v11 cleanup orphans (ref #176)
|
||||||
- 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-04-28 : Bilan SQL aggregate fix `getAccountsPeriodAnchor` (ROW_NUMBER pattern) (ref #175)
|
||||||
- 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-04-25 : License Ed25519 pubkey rotee pour matcher maximus-api LIVE en prod (ref #49)
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Blockers actifs
|
## Blockers actifs
|
||||||
|
|
||||||
|
- #161 — feat(prices): production wiring + smoke test + release (BLOCKED par maximus-api Phase 2)
|
||||||
- #135 / #136 — maximus-api Stripe webhooks license auto-generate (BLOCKED par maximus-api Phase 2)
|
- #135 / #136 — maximus-api Stripe webhooks license auto-generate (BLOCKED par maximus-api Phase 2)
|
||||||
- #53 — online activation + machine limit enforcement (status:needs-fix)
|
- #53 — online activation + machine limit enforcement (status:needs-fix)
|
||||||
- #50 / #52 — Stripe integration desktop + purchase page (status:ready, design en attente)
|
- #50 / #52 — Stripe integration + purchase page (status:ready, design en attente)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
# ADR 0012 — Modèle à deux niveaux pour le Bilan (véhicules × compositions)
|
# ADR 0012 — Modèle à deux niveaux pour le Bilan (véhicules × compositions)
|
||||||
|
|
||||||
- Status: **Rejected** (jamais accepté ; reste à l'état de proposition)
|
- Status: Proposed
|
||||||
- Date: 2026-05-01 (proposé) · 2026-06-01 (rejeté)
|
- Date: 2026-05-01
|
||||||
- Issue: #179
|
- Issue: #179
|
||||||
- Rejeté au profit de : [ADR 0014](0014-balance-vehicule-attribut.md)
|
|
||||||
|
|
||||||
> **Rejet (2026-06-01).** L'audit Bilan (`docs/audit-bilan-2026-05.md`) a retenu une **trajectoire additive** plutôt que ce modèle à deux tables surdimensionné : l'ADR 0014 fait du véhicule fiscal un simple **attribut nullable du compte** (`vehicle_type`) et de la catégorie une **pure classe d'actif**, sans réécriture de `/balance` ni migration massive. Le grain visé par 0012 (triplet véhicule × *composition*) restait par ailleurs au niveau **classe d'actif**, pas **titre** — il déplaçait le mur sans débloquer le détail par titre. *Rejected* (et non *Superseded*) : 0012 n'a jamais quitté l'état `Proposed`, donc aucune décision active n'est remplacée. La réflexion sur les groupements croisés (`GROUP BY véhicule` / `GROUP BY classe d'actif`) reste valable et est reprise par 0014.
|
|
||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
# ADR 0013 — Évaluation provider stocks : Alpha Vantage retenu comme cible de bascule (override partiel ADR 0011)
|
|
||||||
|
|
||||||
- Status: **Accepted**
|
|
||||||
- Date: 2026-05-07
|
|
||||||
- Successor of: ADR 0011 (override partiel — la pré-désignation **Tiingo Power** comme cible de bascule devient invalide ; **Alpha Vantage Premium** la remplace)
|
|
||||||
- Issue: [maximus-api#41](https://git.lacompagniemaximus.com/maximus/maximus-api/issues/41)
|
|
||||||
- Phase 1 research note: [maximus-api/docs/research/0013-stocks-providers-phase1.md](https://git.lacompagniemaximus.com/maximus/maximus-api/src/branch/issue-41-stocks-providers-research/docs/research/0013-stocks-providers-phase1.md)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
L'ADR 0011 (2026-04-26) a adopté Yahoo Finance en best-effort pour les stocks, **avec un plan de bascule pré-désigné vers Tiingo Power (~10 $/mo, 1000 req/jour)** déclenché par triggers (1+ IP-ban/mois × 2 mois consécutifs, ou 30 %+ requêtes en service_degraded sur 7 jours, ou plainte légale).
|
|
||||||
|
|
||||||
Le smoke test 2026-05-04 (issue #25) a confirmé que Yahoo bloque l'IP du VPS OVH de manière stable. Un trigger ADR 0011 est de fait actif. Le feature stocks est non-fonctionnel en production.
|
|
||||||
|
|
||||||
**Décision encadrante (le présent ADR ne la modifie pas)** : on reste dans l'esprit MVP de l'ADR 0011 — **0 $ de cash burn tant que le produit n'a pas validé son marché**. La bascule vers un provider payant est repoussée jusqu'à un trigger plus net (1ère licence payée, OU 1ère plainte client active, OU saturation des plaintes "stocks cassé"). Le scope du présent ADR est de **valider empiriquement quel provider sera la cible de bascule** quand un trigger réel se déclenchera, **pas** de déclencher la bascule maintenant.
|
|
||||||
|
|
||||||
Avant de figer mécaniquement Tiingo Power comme dans 0011, l'évaluation a été élargie à 3 providers — Alpha Vantage, Tiingo, Polygon — pour potentiellement override la pré-désignation 0011 si un autre provider domine.
|
|
||||||
|
|
||||||
## Phase 1 — Recherche documentaire (sources publiques uniquement)
|
|
||||||
|
|
||||||
3 sous-agents WebSearch ont produit une synthèse 6-axes par provider. Synthèse complète dans `maximus-api/docs/research/0013-stocks-providers-phase1.md`.
|
|
||||||
|
|
||||||
Findings critiques :
|
|
||||||
|
|
||||||
| Critère | Alpha Vantage | Tiingo | Polygon |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **TSX coverage** | ✅ via `.TRT` / `.TRV` | ❓ non confirmé publiquement | ❌ non couvert |
|
|
||||||
| **Plan technique min pour 1500 rpm × 10k/jour** | aucun palier public ≥ 1500 rpm (top 1200 rpm @ ~$249.99/mo) | Power suffit techniquement (~$30/mo, 100k/jour) | Starter suffit techniquement ($29/mo "unlimited") |
|
|
||||||
| **ToS proxy mutualisé pour clients tiers payants** | ⚠️ zone grise, pas de clause explicite, email business pour cas commercial | ❌ Power = "internal consumption only" → Commercial $500+/mo + redistribution license | ❌ Individuals ToS interdit explicitement → Business négocié obligatoire |
|
|
||||||
| **HTTP error model** | ⚠️ 200 OK + champ `Note`/`Information` | ⚠️ 200 OK + body non-JSON sur quota | ✅ HTTP 429 standard |
|
|
||||||
| **Profondeur historique daily** | 20+ ans | 30+ ans US | 5 ans Starter |
|
|
||||||
| **Free tier viable pour dev** | ✅ 25 req/jour, 5 req/min — OK pour valider l'intégration | ⚠️ 1000 req/jour mais ToS interdit le commercial sans Commercial plan | ⚠️ 5 req/min, EOD only, ToS interdit le commercial sans Business |
|
|
||||||
|
|
||||||
**Finding majeur** : la pré-désignation ADR 0011 « Tiingo Power ~10 $/mo » est obsolète sur le prix (réel 2026 ≈ $30/mo) ET invalide sur le ToS (Power = internal-use only). Notre cas d'usage cible (proxy multi-tenant) forcerait Tiingo en plan Commercial ($500+/mo) avec redistribution license négociée. Polygon idem (Business plan, prix non public). Alpha Vantage seul reste en zone grise sans interdiction explicite et offre un free tier exploitable pour valider l'intégration en dev.
|
|
||||||
|
|
||||||
## Phase 2 — Smoke test live Alpha Vantage (2026-05-07, free tier)
|
|
||||||
|
|
||||||
13 calls réels sur `https://www.alphavantage.co/query` avec une clé free tier. Réponses brutes archivées dans `~/.maximus-research-keys/raw-av.json` (à supprimer après validation de cet ADR).
|
|
||||||
|
|
||||||
| # | Test | Résultat | Verdict |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01 | `GLOBAL_QUOTE` AAPL | $287.44, day 2026-05-07 | Happy path ✅ |
|
|
||||||
| 02 | `TIME_SERIES_DAILY_ADJUSTED` AAPL | `Information` field — premium endpoint | ⚠️ **Adjusted close = premium $49.99/mo minimum** |
|
|
||||||
| 03 | `GLOBAL_QUOTE SHOP.TRT` | $152.57 CAD | TSX coverage confirmée ✅ |
|
|
||||||
| 04 | `GLOBAL_QUOTE RY.TRT` | $247.64 CAD | TSX big caps OK ✅ |
|
|
||||||
| 05 | **`GLOBAL_QUOTE SHOP.TO`** (Yahoo style) | **$152.57 — alias silencieux de `.TRT`** | 🎉 **Pas de mapping table requis** — drop-in Yahoo |
|
|
||||||
| 06 | `GLOBAL_QUOTE SHOP` (sans suffixe) | $111.74 — listing US différent | Suffixe nécessaire pour désambiguïsation CA vs US |
|
|
||||||
| 07 | `GLOBAL_QUOTE XYZAB123` (inconnu) | `"Global Quote": {}` (objet vide) | ⚠️ **Pas de `Error Message`** — détection = empty object |
|
|
||||||
| 08 | `GLOBAL_QUOTE SPX` | objet racine `{}` vide | ❌ Indices broad-market non couverts |
|
|
||||||
| 09 | `GLOBAL_QUOTE ^GSPC` | objet racine `{}` vide | ❌ Idem |
|
|
||||||
| 10 | `GLOBAL_QUOTE VTSAX` (mutual fund) | $176.23, day 2026-05-06 | Mutual funds OK ✅ |
|
|
||||||
| 11 | `GLOBAL_QUOTE BRK.B` (share class) | $475.08 | Format dot natif ✅ |
|
|
||||||
| 12 | `SYMBOL_SEARCH keywords=shopify` | 5 résultats incluant `SHOP.TRT` Toronto | Convention `.TRT` confirmée par AV |
|
|
||||||
| 13 | `TIME_SERIES_DAILY_ADJUSTED` AAPL outputsize=full | `Information` field — premium endpoint | ⚠️ Premium-gate global sur l'historique ajusté |
|
|
||||||
|
|
||||||
**Headers HTTP** : `Retry-After` absent sur les 13 réponses. Toutes en HTTP 200 (confirme la doc — pas de 4xx propres). Content-Type `application/json` partout.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Override partiel de l'ADR 0011 — uniquement le provider de bascule désigné** :
|
|
||||||
|
|
||||||
- L'ADR 0011 reste en vigueur sur tout le reste : Yahoo best-effort en prod, garde-fous (badge UX, circuit breaker, quota 200 req/jour/licence, saisie manuelle toujours active), triggers de migration inchangés.
|
|
||||||
- **Le provider de bascule désigné passe de Tiingo Power à Alpha Vantage Premium $49.99/mo (75 rpm)** quand un trigger ADR 0011 se déclenchera réellement.
|
|
||||||
- **Aucune bascule immédiate.** Yahoo best-effort reste en prod tant qu'aucun trigger réel (1ère licence payée, plainte client formelle, saturation des incidents) ne justifie le cash burn.
|
|
||||||
|
|
||||||
### Pourquoi Alpha Vantage plutôt que Tiingo (pré-désignation 0011)
|
|
||||||
|
|
||||||
1. **Tiingo Power est invalide pour notre cas** : "internal consumption only" dans le ToS. Notre proxy multi-tenant force Tiingo en plan Commercial ~$500/mo + redistribution license — ~10× plus cher que la pré-désignation 0011 ($10/mo). Le rapport coût/bénéfice supposé par 0011 ne tient plus.
|
|
||||||
2. **Alpha Vantage Premium $49.99/mo** est le palier le moins cher qui (a) couvre le besoin technique avec marge (75 rpm × 1440 = ~108k req/jour, vs cible 10k/jour), (b) inclut `TIME_SERIES_DAILY_ADJUSTED` confirmé empiriquement comme premium-gate, (c) couvre le TSX nativement.
|
|
||||||
3. **Drop-in Yahoo via `.TO` natif** — découverte Phase 2 majeure : AV accepte le suffixe Yahoo `.TO` silencieusement comme alias de `.TRT`. **Aucune mapping table à coder.** Le code `yahooProvider.ts` existant peut être copié quasi-tel-quel en `alphaVantageProvider.ts`. C'est l'argument décisif pour la bascule rapide quand elle sera déclenchée — délai d'implémentation ~quelques heures, pas quelques jours.
|
|
||||||
4. **ToS en zone grise = négociable au moment voulu** : pas d'interdiction explicite (vs Polygon Individuals qui interdit, vs Tiingo Power qui interdit, vs Yahoo qui interdit). Email à `support@alphavantage.co` peut être envoyé au moment du déclenchement, pas avant.
|
|
||||||
|
|
||||||
### Polygon écarté
|
|
||||||
|
|
||||||
Polygon est techniquement supérieur (data quality, "unlimited" rpm sur Starter $29) mais **disqualifié seul par l'absence de couverture TSX**. Une stratégie hybride Polygon US + AV CA serait plus complexe et plus chère pour un bénéfice marginal vs AV seul.
|
|
||||||
|
|
||||||
### État du free tier AV (statu quo dev)
|
|
||||||
|
|
||||||
La clé free tier obtenue pendant l'évaluation reste active pour :
|
|
||||||
- **Dev / smoke test continu** : valider l'intégration en local avant tout déploiement payant.
|
|
||||||
- **Smoke test périodique de la cible de bascule** : 1× par mois, 5-10 calls pour vérifier que la convention `.TO` fonctionne toujours, que les premium-gates n'ont pas changé, que `support@alphavantage.co` n'a pas resserré le free tier.
|
|
||||||
|
|
||||||
La clé reste hors-Coolify (jamais déployée en prod), dans `~/.maximus-research-keys/av.txt` côté machine de dev. **À ne pas confondre avec un déploiement de prod** — le free tier 25 req/jour est 400× insuffisant pour servir 50 licences réelles.
|
|
||||||
|
|
||||||
## Plan de bascule (déclenché par trigger ADR 0011, pas maintenant)
|
|
||||||
|
|
||||||
Quand un trigger réel se déclenchera :
|
|
||||||
|
|
||||||
1. **Décision business** : confirmer que la bascule est justifiée vs amender ADR 0011 (rester sur Yahoo + mitigations alternatives).
|
|
||||||
2. **Email ToS** à `support@alphavantage.co` (draft inclus en annexe ci-dessous) — à envoyer **à ce moment-là**, pas maintenant. Délai de réponse standard 3-5 j ouvrables. Use case précisé : "server-side proxy serving N paying B2B licensees".
|
|
||||||
3. **Sur réponse positive** : signup Premium $49.99/mo (75 rpm). Clé en var Coolify `ALPHAVANTAGE_API_KEY` (secret). Issue follow-up `feat(api): integrer Alpha Vantage comme provider stocks` créée à ce moment-là.
|
|
||||||
4. **Sur réponse négative ou zone grise prolongée** : amender cet ADR pour retomber sur Tiingo Commercial ($500+/mo) — financièrement justifiable seulement si l'audience est suffisamment monétisée pour absorber le coût.
|
|
||||||
5. **Implémentation variante A** (remplacement direct, switch via env var `STOCKS_PROVIDER=alphavantage`). Yahoo retiré du code dans une seconde PR séquencée pour rollback rapide.
|
|
||||||
6. **Smoke test prod** : `?symbol=AAPL` et `?symbol=SHOP.TO` doivent renvoyer 200 + prix non-stale.
|
|
||||||
7. **Bascule + monitoring 7 jours**.
|
|
||||||
8. **Cleanup** : `~/.maximus-research-keys/` supprimé.
|
|
||||||
|
|
||||||
## Garde-fous obligatoires (mirror ADR 0011, applicables à AV au moment de la bascule)
|
|
||||||
|
|
||||||
1. **Label UX inchangé** : badge "best-effort" reste sur les catégories stocks. AV est plus stable que Yahoo mais reste un provider tiers — pas de SLA contractuel pour notre cas d'usage à $49.99/mo.
|
|
||||||
2. **Circuit breaker côté maximus-api** : seuil identique 5 erreurs AV / 60 sec → breaker ouvert 15 min. Notification Telegram/email à `maxime2tremblay@protonmail.com`.
|
|
||||||
3. **Quota côté maximus-api** : 200 req/jour/licence — inchangé. Avec 75 rpm × 1440 min = 108 000 req/jour de capacité, marge ample pour 50 licences.
|
|
||||||
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
|
|
||||||
5. **Headers stripping rigoureux** : tous les headers entrants supprimés. Vers AV : UA `maximus-api/<version>` (pas besoin de UA browser-like contrairement à Yahoo). Auth via query string `?apikey=` (limitation AV — pas de header support).
|
|
||||||
6. **Logs** : URL avec `?apikey=` masquée dans les logs Coolify/Traefik via une regex de filtre côté pino logger.
|
|
||||||
|
|
||||||
## Parsing défensif (issu des findings Phase 2 — applicable au moment de l'implémentation)
|
|
||||||
|
|
||||||
Le module `alphaVantageProvider.ts` doit gérer **5 cas distincts** sur HTTP 200 :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. Happy path — body['Global Quote'] populated
|
|
||||||
if (body['Global Quote'] && Object.keys(body['Global Quote']).length > 0) { /* ok */ }
|
|
||||||
// 2. Symbol unknown — body['Global Quote'] = {} empty object
|
|
||||||
else if (body['Global Quote'] && Object.keys(body['Global Quote']).length === 0) { /* symbol_not_found */ }
|
|
||||||
// 3. Premium endpoint blocked — body['Information'] field with subscribe message
|
|
||||||
else if (body['Information']) { /* premium_required or rate_limit */ }
|
|
||||||
// 4. Error — body['Error Message'] field (param malformed)
|
|
||||||
else if (body['Error Message']) { /* invalid_request */ }
|
|
||||||
// 5. Rate limit hit — body['Note'] field (legacy free tier message)
|
|
||||||
else if (body['Note']) { /* rate_limit */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
Pas de fallback sur HTTP status (toujours 200). Le code de parsing yahoo existant ne couvre pas ces cas — adaptation requise dans la PR follow-up déclenchée par la bascule.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positives
|
|
||||||
|
|
||||||
- **0 $ de cash burn maintenu** — l'esprit MVP de l'ADR 0011 est préservé. Pas de bascule prématurée à un provider payant.
|
|
||||||
- **Cible de bascule validée empiriquement** — `.TO` natif, TSX confirmé, mutual funds OK, format API simple. Au moment du trigger, la bascule prendra des heures, pas des jours.
|
|
||||||
- **Override propre de la pré-désignation Tiingo** — la décision 0011 ne s'auto-déclenche pas mécaniquement vers un provider mal calibré.
|
|
||||||
- **Email ToS reporté** — pas d'effort gaspillé tant qu'il n'y a pas d'enjeu réel.
|
|
||||||
|
|
||||||
### Négatives / risques actés
|
|
||||||
|
|
||||||
- **Yahoo reste cassé en prod** — feature stocks non-fonctionnel jusqu'au déclenchement du trigger ou résolution Yahoo (improbable). Acceptable tant qu'aucun client payant ne se plaint.
|
|
||||||
- **Adjusted close = premium endpoint** chez AV : confirmé empiriquement. Au moment de la bascule, le tier Premium $49.99 sera nécessaire (pas de chemin gratuit pour `TIME_SERIES_DAILY_ADJUSTED`).
|
|
||||||
- **Indices broad-market non couverts via `GLOBAL_QUOTE`** : SPX, ^GSPC, GSPTSE retournent objet vide. Hors-scope tant que la roadmap ne demande pas d'indices ; sinon ETF proxy (`SPY`, `XIC.TO`).
|
|
||||||
- **HTTP 200 sur toutes les erreurs** : parsing fragile, code défensif obligatoire (5 cas distincts à gérer).
|
|
||||||
- **Pas de `Retry-After` natif** : exponential backoff côté client requis sur détection de `Note`/`Information`.
|
|
||||||
- **Auth `?apikey=` query string uniquement** : leak risk dans les logs Coolify/Traefik. Mitigation = regex de masking pino.
|
|
||||||
- **ToS en zone grise jusqu'à confirmation écrite future** : risque de suspension de clé sans préavis si AV détecte un pattern multi-tenant. Probabilité faible à petite échelle, à monitorer.
|
|
||||||
- **Profondeur smallcaps TSXV non validée** : risque sur ~5-10 % des positions clients (à mitiger en Phase 4 par smoke test sur échantillon représentatif des holdings réels).
|
|
||||||
- **Free tier érodé historiquement** (500 → 100 → 25 req/jour) : signal qu'AV peut resserrer aussi les paid tiers à terme. Risque budget sur 12-24 mois.
|
|
||||||
|
|
||||||
### Neutre
|
|
||||||
|
|
||||||
- Le code reste `src/services/providers/<provider>Provider.ts` parallèle, switch via env var. Pattern déjà en place pour Yahoo, extension triviale.
|
|
||||||
|
|
||||||
## Triggers de bascule (rappel ADR 0011, inchangés)
|
|
||||||
|
|
||||||
- 1+ incidents IP-ban Yahoo / mois pendant 2 mois consécutifs, OU
|
|
||||||
- 30 %+ requêtes stocks tombent en `service_degraded` sur 7 jours, OU
|
|
||||||
- Plainte légale formelle de Yahoo / Verizon Media, OU
|
|
||||||
- (nouveau) 1ère licence payée active OU 1ère plainte client formelle sur le feature stocks.
|
|
||||||
|
|
||||||
Le 4ème trigger est ajouté pour aligner la décision avec la réalité business : tant qu'il n'y a pas de licence payée ou de plainte active, le 0 $ recurring l'emporte sur le service-quality.
|
|
||||||
|
|
||||||
## Suivi
|
|
||||||
|
|
||||||
- ADR à reviewer dans 6 mois (2026-11-07) ou plus tôt si :
|
|
||||||
- Trigger de bascule déclenché (réévaluer Yahoo vs AV en fonction du contexte du moment),
|
|
||||||
- AV resserre le free tier au point que le smoke test mensuel devient impossible,
|
|
||||||
- Yahoo redevient stable (improbable mais possible).
|
|
||||||
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`, `paid_licenses_active_count`.
|
|
||||||
- Smoke test mensuel AV free tier : entrée TODO dans le calendrier ou cron `claude` skill.
|
|
||||||
|
|
||||||
## Annexe — Draft email à `support@alphavantage.co` (à envoyer au moment de la bascule, pas maintenant)
|
|
||||||
|
|
||||||
```
|
|
||||||
Subject: Commercial use authorization — server-side proxy for ~N paying B2B licensees
|
|
||||||
|
|
||||||
Hello Alpha Vantage support team,
|
|
||||||
|
|
||||||
I am evaluating Alpha Vantage Premium for a small B2B SaaS use case and would like
|
|
||||||
to confirm the licensing model in writing before subscribing.
|
|
||||||
|
|
||||||
Use case:
|
|
||||||
- Server-side proxy (single VPS, single API key) on behalf of ~N paying B2B licensees
|
|
||||||
- Delayed/EOD US equities (NYSE/NASDAQ/AMEX) and Canadian equities (TSX via .TRT/.TO)
|
|
||||||
- Mutual funds (limited)
|
|
||||||
- ~Y 000 requests/day total, ~Z req/min peak
|
|
||||||
- No client-side redistribution: only the licensee's own portfolio holdings are
|
|
||||||
fetched, and the response data is consumed by the licensee's own desktop app
|
|
||||||
(no public-facing data feed, no resale to non-licensees)
|
|
||||||
|
|
||||||
Question:
|
|
||||||
1. Is this use case authorized under the standard Premium ToS, or do we need
|
|
||||||
a custom commercial agreement / data onboarding process?
|
|
||||||
2. Which tier would you recommend for the volume above?
|
|
||||||
3. Are there any per-end-user fees or exchange data fees I should be aware of
|
|
||||||
for delayed/EOD US + Canadian equities?
|
|
||||||
|
|
||||||
I would prefer to have written confirmation before subscribing, to comply with
|
|
||||||
my own internal documentation requirements (the ToS confirmation will be filed
|
|
||||||
as part of an internal architecture decision record).
|
|
||||||
|
|
||||||
Thanks for your help,
|
|
||||||
Maxime Tremblay
|
|
||||||
maxime2tremblay@protonmail.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Réponse écrite à archiver dans `simpl-resultat/docs/adr/0013-attachments/alphavantage-tos-confirmation-YYYY-MM-DD.txt` une fois reçue.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Alpha Vantage — API Documentation](https://www.alphavantage.co/documentation/)
|
|
||||||
- [Alpha Vantage — Premium API Key (pricing)](https://www.alphavantage.co/premium/)
|
|
||||||
- [Alpha Vantage — Terms of Service](https://www.alphavantage.co/terms_of_service/)
|
|
||||||
- [Alpha Vantage — Customer Support](https://www.alphavantage.co/support/)
|
|
||||||
- [Macroption — Alpha Vantage Symbols (suffixes)](https://www.macroption.com/alpha-vantage-symbols/)
|
|
||||||
- ADR 0009 — Architecture du proxy
|
|
||||||
- ADR 0011 — Providers best-effort Yahoo (override partiel sur le provider de bascule désigné)
|
|
||||||
- `maximus-api/docs/research/0013-stocks-providers-phase1.md` — Synthèse 3-way Phase 1
|
|
||||||
- `~/.maximus-research-keys/raw-av.json` — Réponses brutes Phase 2 smoke test (à supprimer après merge)
|
|
||||||
- Issue Forgejo `maximus-api#41`
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
# ADR 0014 — Bilan : le véhicule fiscal est un attribut du compte, la classe d'actif est la catégorie
|
|
||||||
|
|
||||||
- Status: **Accepted**
|
|
||||||
- Date: 2026-06-01
|
|
||||||
- Rejette: ADR 0012 (modèle à deux niveaux véhicule × composition — voir « Alternatives » ci-dessous)
|
|
||||||
- Issues: #202 (couche données), #203 (UI saisie), #204 (UI suivi), #205 (cet ADR + doc)
|
|
||||||
- Audit source: [docs/audit-bilan-2026-05.md](../audit-bilan-2026-05.md)
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
L'audit critique de la page Bilan (`docs/audit-bilan-2026-05.md`, revue CPA + UX, 2026-05-31) a identifié une **racine unique** (finding A) derrière la plupart des frictions du module : le modèle est *plat*. Une seule « catégorie » (`balance_categories`) encodait simultanément **deux axes orthogonaux** :
|
|
||||||
|
|
||||||
- le **véhicule fiscal** / l'enveloppe : CELI, REER, non-enregistré… ;
|
|
||||||
- la **classe d'actif** : liquidités, fonds/FNB, actions, crypto, autres.
|
|
||||||
|
|
||||||
Les 7 catégories seedées par la migration v9 — `cash · tfsa · rrsp · fund · other · stock · crypto` — mélangeaient donc des frères/sœurs de natures incomparables. Conséquences directes mesurées par l'audit :
|
|
||||||
|
|
||||||
- **Cas québécois inexprimables** : suivre des actions *dans* un CELI force un choix dégradé (compte `simple` sous `tfsa` → le titre disparaît ; compte `priced` sous `stock` → l'abri CELI disparaît).
|
|
||||||
- **Empilé « par catégorie » illisible** (finding G) : ni « répartition par classe d'actif », ni « répartition par enveloppe » — un axe bâtard.
|
|
||||||
- **Bug i18n latent** (finding I) : renommer une catégorie via `window.prompt()` écrasait `i18n_key` avec du texte libre, cassant la traduction FR/EN — une promesse-socle du projet.
|
|
||||||
|
|
||||||
L'audit recommandait une **trajectoire additive en trois temps** (Étape 0 quick wins, déjà livrée en #201 ; Étape 1 séparer l'axe véhicule ; Étape 2 détail par titre) explicitement opposée au big-bang de l'ADR 0012. Le présent ADR documente la décision prise pour l'**Étape 1**.
|
|
||||||
|
|
||||||
### Pourquoi additif (et pas le modèle deux-tables de l'ADR 0012)
|
|
||||||
|
|
||||||
L'ADR 0012 proposait deux tables `balance_vehicles` × `balance_compositions` et une ligne de snapshot devenant un triplet `(vehicle_id, composition_id, value)`. C'est une réécriture quasi totale de `/balance` (grille de saisie 2D imposée à tous, dédoublement des agrégateurs, migration de données massive) pour un grain qui reste la **classe d'actif** — pas le **titre individuel** que l'investisseur attend réellement (audit : « l'ADR 0012 résout le mauvais grain »). L'approche retenue ajoute deux colonnes nullables, ne touche aucun compte simple existant, et garde la grille de saisie à une dimension.
|
|
||||||
|
|
||||||
## Décision
|
|
||||||
|
|
||||||
**Le véhicule fiscal devient un attribut du compte ; la catégorie devient une pure classe d'actif.**
|
|
||||||
|
|
||||||
1. **Nouvelle colonne `balance_accounts.vehicle_type`** — TEXT nullable, enveloppe fiscale, contrainte CHECK sur l'enum réduit courant :
|
|
||||||
|
|
||||||
| Code | FR | EN |
|
|
||||||
|---|---|---|
|
|
||||||
| `unregistered` | Non-enregistré | Non-registered |
|
|
||||||
| `tfsa` | CELI | TFSA |
|
|
||||||
| `rrsp` | REER | RRSP |
|
|
||||||
| `rrif` | FERR | RRIF |
|
|
||||||
| `fhsa` | CELIAPP | FHSA |
|
|
||||||
| `resp` | REEE | RESP |
|
|
||||||
|
|
||||||
⚠️ `vehicle_type` = enveloppe **fiscale**, jamais un véhicule automobile. Nullable : un compte chèque ou un wallet crypto n'a pas d'enveloppe (valeur `NULL`, et non `unregistered` — un compte courant n'est pas un placement non-enregistré).
|
|
||||||
|
|
||||||
2. **Les catégories sont 5 classes d'actif pures** : Liquidités, Fonds/FNB, Actions, Crypto, Autres. Les ex-catégories-véhicules `tfsa`/`rrsp` ne sont plus des catégories — elles deviennent des `vehicle_type`.
|
|
||||||
|
|
||||||
3. **Deux migrations additives** (jamais de modification d'une migration ≤ v11 — checksum SHA-384 sqlx) :
|
|
||||||
- **v12 (additive)** : ajoute `vehicle_type` (+ CHECK) et `balance_categories.custom_label` ; backfille `vehicle_type='tfsa'`/`'rrsp'` pour les comptes des ex-enveloppes ; les comptes `cash` restent `NULL`. Inclut un **backfill défensif du bug I** : toute catégorie seed dont `i18n_key` avait été écrasé par du texte libre récupère ce texte dans `custom_label`, et son `i18n_key` est restauré depuis `key`.
|
|
||||||
- **v13 (reclassement, conditionnelle/idempotente)** : re-rattache les comptes ex-`tfsa`/`rrsp` à la classe « Autres » (`other`), gardé par `EXISTS(other) AND is_seed=1` ; désactive (`is_active=0`) les seeds `tfsa`/`rrsp` devenus des véhicules. v12 stampe `vehicle_type` **avant** que v13 ne déplace `balance_category_id` (ordre garanti par le versioning sqlx).
|
|
||||||
- `consolidated_schema.sql` (profils neufs) et les comptes starter (consolidated + `STARTER_ACCOUNTS` service) sont synchronisés : CELI/REER pointent vers `other` + portent leur `vehicle_type`.
|
|
||||||
|
|
||||||
4. **Renommage de catégorie via `custom_label`** (corrige le bug I) : l'affichage suit la règle `custom_label?.trim() || t(i18n_key, { defaultValue: key })`, factorisée dans un helper `renderCategoryLabel`. Le renommage écrit `custom_label` et **ne touche plus jamais `i18n_key`** — la traduction FR/EN reste intacte.
|
|
||||||
|
|
||||||
5. **Deux axes de lecture** : le graphique empilé gagne un sous-toggle « Par classe d'actif » (défaut, = comportement existant) / « Par enveloppe » (`getSnapshotTotalsByVehicleAndDate`, bucket `COALESCE(vehicle_type,'none')`). Le tableau des comptes replie ses colonnes de rendement par défaut, avec un toggle dont l'état est persisté (`user_preferences.balance_show_returns`).
|
|
||||||
|
|
||||||
### Hors scope (Étape 2 — explicitement exclue de cette décision)
|
|
||||||
|
|
||||||
Le **détail par titre** reste hors scope et n'est *pas* tranché par cet ADR :
|
|
||||||
|
|
||||||
- pas de table `balance_securities` (symbole normalisé, devise, asset_type) ;
|
|
||||||
- pas de holdings/positions par titre sous un compte ;
|
|
||||||
- pas de `book_cost` / PRU (distinction apport vs gain latent) ;
|
|
||||||
- pas de migration du `kind` (`simple`/`priced`) de la catégorie vers le compte, donc pas encore d'assistant « détailler un compte agrégé en titres » sans rupture d'historique ;
|
|
||||||
- pas d'import CSV de cours local, pas de multi-devise (reste CAD), pas d'agrégation du rendement par véhicule.
|
|
||||||
|
|
||||||
L'Étape 2 fera l'objet d'un ADR distinct quand le besoin sera confirmé (gating de l'audit : trajectoire additive, pas de big-bang). L'Étape 1 ne **ferme aucune** de ces portes — elle pose l'axe véhicule sans présumer du grain titre.
|
|
||||||
|
|
||||||
## Alternatives considérées
|
|
||||||
|
|
||||||
- **ADR 0012 — modèle à deux niveaux `balance_vehicles` × `balance_compositions`** (rejeté, voir ci-dessus) : surdimensionné, grille 2D imposée, migration massive, grain « classe d'actif » et non « titre ».
|
|
||||||
- **Tagging multi-axes libre** (alternative A de l'ADR 0012) : aucune contrainte sur les combinaisons, rapports « actions dans CELI » coûteux, vocabulaire divergent entre profils.
|
|
||||||
- **Sous-comptes (`parent_id`)** (alternative B de l'ADR 0012) : invariant somme parent = somme enfants à maintenir, snapshots dédoublés sans gain expressif.
|
|
||||||
- **Statu quo** (modèle plat enrichi de catégories user `tfsa_stock`…) : explosion N×M de la taxonomie, friction documentée croissante.
|
|
||||||
|
|
||||||
L'attribut nullable sur le compte gagne sur les quatre : migration triviale (2 colonnes), zéro impact sur les comptes simples existants, deux groupements naturels (`GROUP BY category` / `GROUP BY vehicle_type`), et aucune porte fermée pour l'Étape 2.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positives
|
|
||||||
|
|
||||||
- **Cas québécois exprimables** : « combien dans mon CELI ? » se lit par `vehicle_type`, indépendamment de la classe d'actif détenue.
|
|
||||||
- **Empilé assaini** (finding G) : deux axes distincts et explicites, défaut « par classe d'actif » (zéro surprise vs l'existant).
|
|
||||||
- **Bug i18n corrigé** (finding I) : le renommage n'altère plus la traduction ; le backfill défensif v12 répare les profils déjà cassés.
|
|
||||||
- **Migration sûre** : purement additive, idempotente, gardée ; les comptes simples ne bougent jamais ; `snapshot_lines` référencent `account_id` → **historique des valeurs préservé**.
|
|
||||||
- **Tableau dégonflé** (finding F) : rendements repliés par défaut, état persisté — moins de bruit pour le grand public.
|
|
||||||
|
|
||||||
### Négatives / risques actés
|
|
||||||
|
|
||||||
- **Historique re-affiché sous « Autres »** : l'axe « par classe d'actif » est recalculé sur la **catégorie courante** du compte. Un snapshot pré-migration d'un compte ex-CELI/REER apparaît donc désormais sous « Autres » (et non plus « CELI »/« REER »). **Comportement attendu**, documenté au CHANGELOG et au guide. La lecture par enveloppe, elle, retrouve bien le CELI/REER via `vehicle_type`.
|
|
||||||
- **Pas de migration Down** : v12/v13 sont idempotentes et conditionnelles ; toute correction passe par une v14 (jamais d'édition rétroactive).
|
|
||||||
- **Comptes ex-CELI/REER contenant de vraies actions** : restent un montant agrégé en « Autres » + leur `vehicle_type`. Le détail par titre est l'Étape 2 — non débloqué ici.
|
|
||||||
- **`vehicle_type` lu comme automobile** : risque d'ambiguïté de nommage (un agent d'exploration a déjà halluciné « car/truck »). Mitigé par le CHECK explicite, le commentaire de migration et la table d'enum ci-dessus.
|
|
||||||
|
|
||||||
### Neutre
|
|
||||||
|
|
||||||
- L'enum `vehicle_type` couvre le set courant (6 valeurs). Ajouter CRI, RPDB, etc. plus tard = une nouvelle migration qui élargit le CHECK (jamais une édition de v12).
|
|
||||||
|
|
||||||
## Liens
|
|
||||||
|
|
||||||
- [Audit Bilan 2026-05](../audit-bilan-2026-05.md) — racine (finding A), trajectoire additive en trois temps, recommandation sur l'ADR 0012
|
|
||||||
- [ADR 0012](0012-balance-two-level-model.md) — modèle à deux niveaux, **Rejected** au profit du présent ADR
|
|
||||||
- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz par compte (modèle plat préservé : le rendement reste par compte)
|
|
||||||
- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK RESTRICT sur transferts (contrainte inchangée)
|
|
||||||
- Issues #202 / #203 / #204 / #205 — implémentation Étape 1
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
# ADR 0015 — Bilan : détail par titre (holdings par snapshot)
|
|
||||||
|
|
||||||
- Status: **Accepted**
|
|
||||||
- Date: 2026-06-06
|
|
||||||
- Issues: #210 (schéma/migrations v14/v15 + types), #211 (conversion v16), #212 (service securities + save détaillé), #213 (refonte reducer + dispatch `account.kind`), #214 (UI saisie multi-titres), #215 (assistant « détailler »), #216 (drill-down + gain latent), #217 (tests), #218 (cet ADR + doc)
|
|
||||||
- Spec: [`spec-decisions-bilan-detail-titres.md`](../../spec-decisions-bilan-detail-titres.md), [`spec-plan-bilan-detail-titres.md`](../../spec-plan-bilan-detail-titres.md)
|
|
||||||
- Audit source: [docs/audit-bilan-2026-05.md](../audit-bilan-2026-05.md)
|
|
||||||
- Lève le gating « détail par titre reporté » posé par [ADR 0012](0012-balance-two-level-model.md) (Rejected) et le « Hors scope Étape 2 » de [ADR 0014](0014-balance-vehicule-attribut.md)
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
L'audit critique de la page Bilan (`docs/audit-bilan-2026-05.md`, revue CPA + UX) a défini une **trajectoire additive en trois temps**. L'Étape 0 (quick wins, #201) et l'Étape 1 (axe véhicule, [ADR 0014](0014-balance-vehicule-attribut.md) Accepted) sont livrées. L'**Étape 2 — détail par titre** — était reportée et explicitement hors scope de l'ADR 0014 (« l'Étape 2 fera l'objet d'un ADR distinct quand le besoin sera confirmé »). Ce besoin est confirmé : l'audit le qualifie d'« unique levier qui débloque le progressif pour les deux personas », et l'Étape 1 laisse une limitation connue — un compte ex-CELI/REER contenant de vraies actions reste un **montant agrégé** sous « Autres », sans détail de portefeuille.
|
|
||||||
|
|
||||||
État technique de départ : le modèle **coté** (`priced`) existe déjà, mais à raison d'**un seul titre par compte** — `balance_accounts.symbol` + `balance_snapshot_lines(quantity, unit_price, value, price_source)`, alimenté par le price-fetching maximus-api (ADR 0009/0011/0013). L'Étape 2 ne réinvente pas le `quantité × cours` : elle le **généralise de 1 titre/compte à N titres/compte**, avec un coût d'acquisition (`book_cost`) par position pour distinguer l'apport du gain latent.
|
|
||||||
|
|
||||||
### Pourquoi un ADR distinct (et non une extension de 0014)
|
|
||||||
|
|
||||||
L'ADR 0014 a posé l'axe véhicule en gardant le **grain « classe d'actif »** ; il a explicitement laissé ouverte la question du grain **« titre »**. Le détail par titre touche un autre plan du modèle (positions sous une ligne de snapshot, gain latent, bascule agrégé→détaillé, immortalité des titres référencés) qui mérite sa propre décision tracée — d'où ce 0015.
|
|
||||||
|
|
||||||
## Décision
|
|
||||||
|
|
||||||
**Le détail par titre est stocké par snapshot, sous la ligne agrégée existante, qui reste la source de vérité unique.**
|
|
||||||
|
|
||||||
### 1. Modèle holdings-par-snapshot (additif)
|
|
||||||
|
|
||||||
Trois migrations additives **post-v13** — `v14`, `v15`, `v16` — sans aucune modification des migrations `v1`–`v13` (checksum SHA-384 sqlx). `consolidated_schema.sql` (nouveaux profils) et les seeds sont synchronisés en parallèle.
|
|
||||||
|
|
||||||
- **`v14`** — deux tables :
|
|
||||||
- `balance_securities` : table normalisée et **partagée** des titres. `symbol TEXT NOT NULL UNIQUE COLLATE NOCASE` (forme canonique upper/trim), `currency TEXT NOT NULL DEFAULT 'CAD'` (préparé multi-devise, sans CHECK restrictif), `asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto'))`, `name?`. Un même titre est partagé entre comptes.
|
|
||||||
- `balance_snapshot_holdings` : le détail par titre d'un compte détaillé, rattaché à **sa ligne de snapshot agrégée** (`snapshot_line_id`, `security_id`, `quantity`, `unit_price`, `value`, `book_cost?`, `price_source?`, `price_fetched_at?`), `UNIQUE (snapshot_line_id, security_id)`. Deux index (`_line`, `_security`).
|
|
||||||
- **`v15`** — `balance_accounts.kind` (`'simple' | 'detailed'`, défaut `'simple'`, CHECK) + `balance_accounts.detailed_since` (DATE nullable). Backfill : un compte sous catégorie `kind='priced'` devient `'detailed'`. `balance_categories.kind` est **conservé** comme **défaut suggéré** pour les nouveaux comptes (additif, non supprimé).
|
|
||||||
- **`v16`** — convertit les comptes cotés **existants** (1 titre via `account.symbol`) en comptes `detailed` à **1 position** : création/liaison d'un `balance_securities` depuis le symbole normalisé, miroir de chaque ligne `priced` en holding, puis neutralisation de `quantity`/`unit_price` sur la ligne agrégée — **uniquement** si un holding a effectivement été créé (garde anti-perte de données pour les comptes priced à `asset_type` NULL ou sans symbole, laissés intacts). Idempotente façon v13.
|
|
||||||
|
|
||||||
### 2. Invariant central — la ligne agrégée reste la source de vérité
|
|
||||||
|
|
||||||
À chaque snapshot, **un compte = une ligne `balance_snapshot_lines`** (`UNIQUE(snapshot_id, account_id)` inchangé) :
|
|
||||||
|
|
||||||
- compte `simple` : la ligne porte `value` (saisie libre), `quantity`/`unit_price` NULL, aucun holding ;
|
|
||||||
- compte `detailed` : la ligne porte la **valeur totale** du compte — `value = SUM(holdings.value)`, chaque holding arrondi au cent puis sommé (**comparaison exacte au cent, pas de tolérance flottante**) — `quantity`/`unit_price` NULL ; le détail par titre vit dans `balance_snapshot_holdings`.
|
|
||||||
|
|
||||||
**C'est l'invariant qui rend l'Étape 2 non-breaking.** Tous les agrégateurs existants (`getSnapshotTotalsByDate` / `…ByCategoryAndDate` / `…ByVehicleAndDate`) et le **rendement Modified Dietz par compte** ([ADR 0008](0008-modified-dietz-pour-rendement.md), `compute_account_return`) continuent de lire **exactement** `balance_snapshot_lines.value` — zéro changement de chemin de lecture, zéro rebuild des lignes existantes. Les golden values des agrégations sont figées avant/après (tests de régression, #217). Le détail par titre est une **couche d'enrichissement** sous la valeur agrégée, jamais un remplacement.
|
|
||||||
|
|
||||||
### 3. `detailed_since` — pivot faisant autorité
|
|
||||||
|
|
||||||
La frontière agrégé/détaillé est portée par `balance_accounts.detailed_since` (DATE), **faisant autorité** plutôt qu'inférée du comptage de holdings : une ligne **à ou après** `detailed_since` **doit** porter des holdings ; **avant**, l'agrégé figé est toléré (historique pré-bascule en lecture seule). Un compte `detailed` peut donc cumuler des snapshots passés agrégés et des snapshots récents détaillés, mais la frontière est **explicite et requêtable**, conforme à la convention d'état du projet (`archived_at`, `is_active`). La validation est une passe séparée `validateDetailedSnapshot(account.kind, line, holdings)` ; le CHECK SQL par ligne reste inchangé ; la ligne agrégée s'écrit via le chemin « simple ».
|
|
||||||
|
|
||||||
### 4. Dispatch sur `account.kind` (et non `category_kind`)
|
|
||||||
|
|
||||||
Tout le dispatch UI/service bascule de `category_kind` vers **`account.kind`** (`BalanceAccountWithCategory.kind` propagé). `category.kind` (`simple`/`priced`) ne survit que comme **défaut suggéré** de l'`AccountForm` à la création. Un compte `detailed` sous une catégorie `simple` s'affiche donc correctement.
|
|
||||||
|
|
||||||
### 5. Gain latent vs Modified Dietz par titre
|
|
||||||
|
|
||||||
Le **gain/perte latent** d'une position = `value − book_cost`, en valeur et en pourcentage (`/ book_cost`), agrégeable par compte / classe d'actif / enveloppe, avec drill-down par titre dans le tableau des comptes (`computeUnrealizedGain`, garde-fou `book_cost = 0` ou NULL → « N/A »). `book_cost` est **saisi directement par position** (pré-rempli depuis le snapshot précédent) et historisé sur `balance_snapshot_holdings` — il n'est **pas** dérivé de transactions.
|
|
||||||
|
|
||||||
Le **rendement Modified Dietz reste au niveau compte** (inchangé). Un Modified Dietz **par titre** est **hors scope** : il exigerait des flux datés par lot et par titre (achats/ventes/RoC) — c'est-à-dire un suivi par lots, que l'app ne tient pas (modèle snapshot, pas journal de transactions). Le gain latent depuis l'acquisition est exact **sans** ces flux et répond directement au besoin (apport vs plus-value latente).
|
|
||||||
|
|
||||||
### 6. Assistant « détailler un compte agrégé » (date pivot)
|
|
||||||
|
|
||||||
Sur un compte `simple`, une action **« Détailler en titres »** (point d'entrée dans `BalanceAccountsTable`) bascule `kind='detailed'` et fixe `detailed_since = aujourd'hui`. L'historique agrégé passé reste **figé en lecture seule** ; les titres se saisissent au **prochain snapshot normal** (l'assistant ne capture pas de positions initiales). Le retour `detailed → simple` est **interdit dès qu'un holding existe** — bloqué côté UI **et** côté service (`updateBalanceAccount`, erreur typée), pour éviter les holdings orphelins.
|
|
||||||
|
|
||||||
### 7. Politique des titres : immortels une fois référencés
|
|
||||||
|
|
||||||
`balance_snapshot_holdings.security_id` → `balance_securities(id)` **`ON DELETE RESTRICT`** (miroir de `balance_account_transfers.transaction_id`, [ADR 0010](0010-fk-restrict-balance-transfers.md)) : un titre référencé par au moins un holding **ne peut pas être supprimé** — l'historique reste reproductible. La suppression de titre est donc **masquée dans l'UI** (immortalité assumée). `balance_snapshot_holdings.snapshot_line_id` → `balance_snapshot_lines(id)` **`ON DELETE CASCADE`** : supprimer une ligne (ou son snapshot) emporte ses holdings.
|
|
||||||
|
|
||||||
### Hors scope (explicitement exclu)
|
|
||||||
|
|
||||||
- **Suivi des lots d'achat / transactions** (PBR/ACB calculé, gain en capital *réalisé*, remboursement de capital, perte superficielle 61 j). `book_cost` est **saisi**, pas dérivé. Un Modified Dietz par titre en relève aussi.
|
|
||||||
- **Conversion multi-devise réelle** (taux datés, devise de référence). Seule la colonne `currency` est préparée ; affichage et agrégation restent **CAD**. L'affichage natif ≠ CAD est dé-scopé (chemin non testable tant qu'aucun titre non-CAD ne peut être créé).
|
|
||||||
- **Import CSV de cours local** (mode privacy/hors-ligne) : déféré, le price-fetching maximus-api couvre déjà l'acquisition des cours.
|
|
||||||
- **Agrégation du rendement par titre × véhicule fiscal** (`GROUP BY vehicle_type, security`).
|
|
||||||
|
|
||||||
## Alternatives considérées
|
|
||||||
|
|
||||||
### A. Modèle transaction-based (valeur dérivée) — **rejeté**
|
|
||||||
|
|
||||||
L'alternative établie dans l'industrie (cf. **[Portfolio Performance PR #779](https://github.com/portfolio-performance/portfolio/pull/779)** — « historical quotes from transactions ») : ne pas snapshoter de valeur, mais la **dériver** d'un journal de transactions (achats/ventes datés) × cours historiques. **Rejeté consciemment.**
|
|
||||||
|
|
||||||
- L'app est **snapshot-based par design** (saisie périodique manuelle d'un relevé daté), pas un journal de transactions. Tous les chemins Bilan lisent déjà des snapshots.
|
|
||||||
- Le **rendement Modified Dietz tourne déjà par compte** ([ADR 0008](0008-modified-dietz-pour-rendement.md)) à partir des snapshots + transferts taggés — sans valeurs intermédiaires. Passer au transaction-based forcerait à dériver la valeur à chaque date, soit une **refonte complète** du modèle de lecture, pour un gain (PBR exact, gain réalisé) qui est précisément le **suivi par lots hors scope**.
|
|
||||||
- Le **`book_cost` total saisi par position** colle à la référence courtier ([Wealthsimple stocke une *book value* totale, pas des lots](https://help.wealthsimple.com/hc/en-ca/articles/4409775037083-What-is-adjusted-cost-base-ACB)) ; le snapshot par titre est l'extension **additive** naturelle (cf. [Portfolio Performance — Securities/Transactions/Quotes séparés](https://help.portfolio-performance.info/en/reference/view/securities/all-securities/)).
|
|
||||||
|
|
||||||
### B. Table de positions courantes (hors snapshot) — rejeté
|
|
||||||
|
|
||||||
Maintenir une table « portefeuille courant » distincte des snapshots. Rejeté : incohérent avec le modèle snapshot (le portefeuille courant = le dernier snapshot), et la valeur agrégée ne vivrait plus sur la ligne → agrégations à dédoubler. Stocker les holdings **par snapshot** garde les agrégations existantes intactes et la migration additive sans rebuild.
|
|
||||||
|
|
||||||
### C. Tagging multi-axes / sous-comptes (`parent_id`) — déjà rejetés en 0012/0014
|
|
||||||
|
|
||||||
Ne donnent pas le grain titre et imposent un invariant somme parent = somme enfants. Voir [ADR 0012](0012-balance-two-level-model.md) (alternatives A/B) et [ADR 0014](0014-balance-vehicule-attribut.md).
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positives
|
|
||||||
|
|
||||||
- **Détail de portefeuille enfin exprimable** : N titres (quantité, cours, valeur, `book_cost`) dans un même compte ; un ex-CELI/REER avec de vraies actions n'est plus un montant agrégé opaque — il se détaille via l'assistant.
|
|
||||||
- **Non-breaking par construction** : la ligne agrégée reste la source de vérité ; agrégations date/classe/enveloppe et Modified Dietz par compte **inchangés** (golden values figées, #217). Migration purement additive, idempotente, atomique (rollback testé), `v1`–`v13` intactes.
|
|
||||||
- **Gain latent exact sans suivi par lots** : `value − book_cost` répond aux findings D/E de l'audit sans le coût d'un journal de transactions.
|
|
||||||
- **Frontière agrégé/détaillé explicite** : `detailed_since` faisant autorité, pas d'état implicite ; les comptes cotés existants sont auto-convertis sans perte d'historique.
|
|
||||||
- **Reproductibilité préservée** : titres immortels une fois référencés (RESTRICT), holdings emportés par CASCADE avec leur ligne/snapshot.
|
|
||||||
|
|
||||||
### Négatives / risques actés
|
|
||||||
|
|
||||||
- **`book_cost` pré-rempli ≠ ajusté automatiquement** : la copie depuis le snapshot précédent est correcte tant qu'aucun apport/retrait n'a eu lieu sur le titre ; un achat/vente doit être ajusté manuellement, sinon gain latent (`book_cost`) et rendement (Dietz, via les transferts) peuvent diverger silencieusement. Documenté au guide.
|
|
||||||
- **Pas de gain en capital réalisé / PBR fiscal** : le `book_cost` est une *book value* estimée, pas un ACB fiscalement opposable (pas de RoC, pas de perte superficielle). Hors scope assumé.
|
|
||||||
- **Titres orphelins non nettoyables** : un titre référencé puis « abandonné » (qty 0) reste indélébile (RESTRICT). Acceptable (mêmes propriétés que `balance_account_transfers`) ; un nettoyage gardé « si aucun holding » pourra être ajouté plus tard.
|
|
||||||
- **`book_cost` NULL sur l'historique converti** : les lignes converties par v16 n'ont pas de coût rétroactif → gain latent « N/A » avant la première saisie. Attendu.
|
|
||||||
- **Pas de migration Down** : v14/v15/v16 idempotentes et conditionnelles ; toute correction passe par une `v17+` (jamais d'édition rétroactive).
|
|
||||||
|
|
||||||
### Neutre
|
|
||||||
|
|
||||||
- La colonne `currency` est prête mais inerte (CAD partout) tant qu'aucun titre non-CAD ne peut être créé — l'UX d'affichage natif s'activera avec le multi-devise.
|
|
||||||
|
|
||||||
## Liens
|
|
||||||
|
|
||||||
- [Audit Bilan 2026-05](../audit-bilan-2026-05.md) — trajectoire additive en trois temps ; Étape 2 = levier central
|
|
||||||
- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz **par compte** (préservé tel quel ; pas de Dietz par titre)
|
|
||||||
- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK `RESTRICT` (modèle d'immortalité réutilisé pour `security_id`)
|
|
||||||
- [ADR 0012](0012-balance-two-level-model.md) — Rejected ; levait le gating « détail par titre reporté »
|
|
||||||
- [ADR 0014](0014-balance-vehicule-attribut.md) — axe véhicule (Étape 1) ; détail par titre y était explicitement hors scope
|
|
||||||
- Spec : [`spec-decisions-bilan-detail-titres.md`](../../spec-decisions-bilan-detail-titres.md) · [`spec-plan-bilan-detail-titres.md`](../../spec-plan-bilan-detail-titres.md)
|
|
||||||
- [Portfolio Performance PR #779](https://github.com/portfolio-performance/portfolio/pull/779) — modèle transaction-based **rejeté** au profit du snapshot
|
|
||||||
- [Wealthsimple — Adjusted cost base](https://help.wealthsimple.com/hc/en-ca/articles/4409775037083-What-is-adjusted-cost-base-ACB) · [AdjustedCostBase.ca](https://www.adjustedcostbase.ca/blog/how-to-calculate-adjusted-cost-base-acb-and-capital-gains/) — `book_cost` total comme modèle légitime
|
|
||||||
- Issues #210 → #218 — implémentation Étape 2
|
|
||||||
|
|
@ -73,7 +73,7 @@ simpl-resultat/
|
||||||
|
|
||||||
## Base de données
|
## Base de données
|
||||||
|
|
||||||
### Tables (20)
|
### Tables (18)
|
||||||
|
|
||||||
| Table | Description |
|
| Table | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
|
|
@ -90,19 +90,17 @@ simpl-resultat/
|
||||||
| `budget_template_entries` | Catégories et montants dans les modèles |
|
| `budget_template_entries` | Catégories et montants dans les modèles |
|
||||||
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
||||||
| `user_preferences` | Préférences applicatives (clé-valeur) |
|
| `user_preferences` | Préférences applicatives (clé-valeur) |
|
||||||
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
|
| `balance_categories` | Taxonomie des types d'actifs (cash, TFSA, RRSP, fund, stock, crypto, other) — `kind ∈ {simple, priced}`, 7 seedées (`is_seed = 1`) |
|
||||||
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
|
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. **Issue #179** : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils via `consolidated_schema.sql`, et proposés aux profils existants via `StarterAccountsModal` (one-shot, pref `balance_starter_proposed`). Le futur passage à un modèle véhicule × composition est décrit dans [ADR 0012](adr/0012-balance-two-level-model.md) (Proposed) |
|
||||||
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
||||||
| `balance_snapshot_lines` | Une ligne par `(snapshot, compte)` — **source de vérité agrégée**. `simple` : `value` seul. `priced`/`detailed` : la ligne porte la valeur **totale** (`value = SUM(holdings.value)`, `quantity`/`unit_price` NULL pour un compte détaillé), le détail vit dans `balance_snapshot_holdings`. Les agrégateurs et Modified Dietz lisent uniquement cette `value` ([ADR 0015](adr/0015-balance-detail-par-titre.md)) |
|
| `balance_snapshot_lines` | Une ligne par `(snapshot, compte)`. Stockage denormalisé : pour `simple` `value` seul, pour `priced` `quantity + unit_price + value`. CHECK kind invariants côté SQL |
|
||||||
| `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains |
|
| `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains |
|
||||||
| `balance_securities` | Table normalisée et **partagée** des titres (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `symbol` UNIQUE `COLLATE NOCASE` (canonique upper/trim), `currency` (DEFAULT `CAD`, préparé multi-devise), `asset_type ∈ {stock, crypto}`, `name?`. Référencée en `ON DELETE RESTRICT` → un titre référencé est immortel (suppression masquée UI) |
|
|
||||||
| `balance_snapshot_holdings` | Détail par titre d'un compte détaillé, rattaché à sa **ligne de snapshot agrégée** (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `snapshot_line_id` (FK CASCADE), `security_id` (FK RESTRICT), `quantity`, `unit_price`, `value` (= qty × prix, arrondi cent), `book_cost?` (gain latent = `value − book_cost`), `price_source?`, `price_fetched_at?`. `UNIQUE(snapshot_line_id, security_id)` |
|
|
||||||
|
|
||||||
### Index (24)
|
### Index (16)
|
||||||
|
|
||||||
Index existants (15) : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
|
Index existants (9) : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
|
||||||
|
|
||||||
Index Bilan (9) — 7 ajoutés en v9 :
|
Index Bilan (7, ajoutés en migration v9) :
|
||||||
- `idx_balance_accounts_category` (FK lookup catégorie → comptes)
|
- `idx_balance_accounts_category` (FK lookup catégorie → comptes)
|
||||||
- `idx_balance_accounts_active` partiel `WHERE is_active = 1` (filtre liste active)
|
- `idx_balance_accounts_active` partiel `WHERE is_active = 1` (filtre liste active)
|
||||||
- `idx_balance_snapshot_lines_snapshot` (chargement d'un snapshot)
|
- `idx_balance_snapshot_lines_snapshot` (chargement d'un snapshot)
|
||||||
|
|
@ -111,25 +109,15 @@ Index Bilan (9) — 7 ajoutés en v9 :
|
||||||
- `idx_balance_account_transfers_transaction` (lookup icône d'attribution dans `TransactionTable`)
|
- `idx_balance_account_transfers_transaction` (lookup icône d'attribution dans `TransactionTable`)
|
||||||
- `idx_balance_snapshots_date` (sélecteur de période + agrégation chronologique)
|
- `idx_balance_snapshots_date` (sélecteur de période + agrégation chronologique)
|
||||||
|
|
||||||
2 ajoutés en v14 (Étape 2 — détail par titre) :
|
|
||||||
- `idx_balance_snapshot_holdings_line` (chargement des positions d'une ligne de snapshot)
|
|
||||||
- `idx_balance_snapshot_holdings_security` (FK lookup titre → positions, garde `ON DELETE RESTRICT`)
|
|
||||||
|
|
||||||
### Invariants Bilan (CHECK + FK)
|
### Invariants Bilan (CHECK + FK)
|
||||||
|
|
||||||
- `balance_categories.kind` ∈ `('simple','priced')` (défaut suggéré pour les nouveaux comptes ; l'axe agrégé/détaillé est désormais porté par `balance_accounts.kind`)
|
- `balance_categories.kind` ∈ `('simple','priced')`
|
||||||
- `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux)
|
- `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux)
|
||||||
- `balance_accounts.vehicle_type` ∈ `('unregistered','tfsa','rrsp','rrif','fhsa','resp')` ou NULL (enveloppe fiscale, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md))
|
- `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced)
|
||||||
- `balance_accounts.kind` ∈ `('simple','detailed')` (v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — un compte `detailed` à/après `detailed_since` doit porter des holdings (validation TS `validateDetailedSnapshot`, pivot faisant autorité)
|
|
||||||
- `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced) ; pour un compte `detailed`, la ligne agrégée porte `value = SUM(holdings.value)` (comparaison exacte au cent), `quantity`/`unit_price` NULL
|
|
||||||
- `balance_securities.symbol` UNIQUE `COLLATE NOCASE` ; `asset_type` ∈ `('stock','crypto')`
|
|
||||||
- `balance_snapshot_holdings` : `UNIQUE (snapshot_line_id, security_id)` (un titre une seule fois par ligne)
|
|
||||||
- `balance_account_transfers.direction` ∈ `('in','out')` ; UNIQUE `(transaction_id, account_id)` (une transaction ne peut pas être liée deux fois au même compte)
|
- `balance_account_transfers.direction` ∈ `('in','out')` ; UNIQUE `(transaction_id, account_id)` (une transaction ne peut pas être liée deux fois au même compte)
|
||||||
- FK `balance_accounts.balance_category_id` → `balance_categories(id)` `ON DELETE RESTRICT` (empêche suppression de catégorie avec comptes liés)
|
- FK `balance_accounts.balance_category_id` → `balance_categories(id)` `ON DELETE RESTRICT` (empêche suppression de catégorie avec comptes liés)
|
||||||
- FK `balance_snapshot_lines.snapshot_id` → `balance_snapshots(id)` `ON DELETE CASCADE` (supprimer un snapshot supprime ses lignes)
|
- FK `balance_snapshot_lines.snapshot_id` → `balance_snapshots(id)` `ON DELETE CASCADE` (supprimer un snapshot supprime ses lignes)
|
||||||
- FK `balance_snapshot_lines.account_id` → `balance_accounts(id)` `ON DELETE RESTRICT` (préserve l'historique)
|
- FK `balance_snapshot_lines.account_id` → `balance_accounts(id)` `ON DELETE RESTRICT` (préserve l'historique)
|
||||||
- FK `balance_snapshot_holdings.snapshot_line_id` → `balance_snapshot_lines(id)` `ON DELETE CASCADE` (supprimer une ligne/snapshot emporte ses holdings)
|
|
||||||
- FK `balance_snapshot_holdings.security_id` → `balance_securities(id)` `ON DELETE RESTRICT` — un titre référencé est immortel (préserve l'historique, miroir de la règle transferts), voir [ADR 0015](adr/0015-balance-detail-par-titre.md)
|
|
||||||
- FK `balance_account_transfers.account_id` → `balance_accounts(id)` `ON DELETE CASCADE`
|
- FK `balance_account_transfers.account_id` → `balance_accounts(id)` `ON DELETE CASCADE`
|
||||||
- FK `balance_account_transfers.transaction_id` → `transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
|
- FK `balance_account_transfers.transaction_id` → `transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
|
||||||
|
|
||||||
|
|
@ -148,13 +136,6 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
|
||||||
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
|
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
|
||||||
| 8 | v8 | Migration de catégories (cf. release 0.8.x) |
|
| 8 | v8 | Migration de catégories (cf. release 0.8.x) |
|
||||||
| 9 | v9 | Schéma Bilan : 5 tables + 7 index + seed des 7 catégories standard (cash, TFSA, RRSP, fund, stock, crypto, other) |
|
| 9 | v9 | Schéma Bilan : 5 tables + 7 index + seed des 7 catégories standard (cash, TFSA, RRSP, fund, stock, crypto, other) |
|
||||||
| 10 | v10 | Ajout `asset_type` sur `balance_categories` (stock/crypto) + backfill des 2 catégories cotées |
|
|
||||||
| 11 | v11 | Nettoyage des snapshots Bilan orphelins |
|
|
||||||
| 12 | v12 | Étape 1 : `balance_accounts.vehicle_type` (+ CHECK, backfill ex-CELI/REER) + `balance_categories.custom_label` (+ backfill défensif du bug i18n) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) |
|
|
||||||
| 13 | v13 | Étape 1 : reclasse les comptes ex-tfsa/rrsp vers « Autres », désactive les seeds enveloppes (idempotente) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) |
|
|
||||||
| 14 | v14 | Étape 2 : `balance_securities` + `balance_snapshot_holdings` + 2 index (additive) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
|
|
||||||
| 15 | v15 | Étape 2 : `balance_accounts.kind` (`simple`/`detailed`) + `detailed_since` + backfill depuis `category.kind` (`priced` → `detailed`) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
|
|
||||||
| 16 | v16 | Étape 2 : conversion des comptes cotés existants en détaillés 1-position (security + holding miroir, gardée anti-perte, idempotente) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
|
|
||||||
|
|
||||||
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
|
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
|
||||||
|
|
||||||
|
|
@ -172,7 +153,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
|
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
|
||||||
| `adjustmentService.ts` | Gestion des ajustements |
|
| `adjustmentService.ts` | Gestion des ajustements |
|
||||||
| `budgetService.ts` | Gestion budgétaire |
|
| `budgetService.ts` | Gestion budgétaire |
|
||||||
| `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) |
|
| `dashboardService.ts` | Agrégation données tableau de bord |
|
||||||
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
||||||
| `dataExportService.ts` | Export de données (chiffré) |
|
| `dataExportService.ts` | Export de données (chiffré) |
|
||||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
|
|
@ -185,8 +166,8 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
|
|
||||||
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
|
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
|
||||||
|
|
||||||
1. **CRUD catégories + comptes + titres** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.).
|
1. **CRUD catégories + comptes** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount`, `archiveBalanceAccount`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour permettre à la UI d'afficher des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, etc.).
|
||||||
2. **Snapshots + lines + holdings** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value − book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`.
|
2. **Snapshots + lines** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne — choix simple pour < 20 comptes/snapshot), `deleteSnapshot`, helper `validateLineKindInvariants` exporté pour les tests (kind invariants TS en complément du CHECK SQL ; tolérance `PRICED_VALUE_TOLERANCE = 0.01` pour le match `value ≈ quantity × unit_price`).
|
||||||
3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
|
3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
|
||||||
4. **Prices** — *(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
|
4. **Prices** — *(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
|
||||||
|
|
||||||
|
|
@ -205,8 +186,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useImportHistory` | Historique des imports |
|
| `useImportHistory` | Historique des imports |
|
||||||
| `useAdjustments` | Ajustements |
|
| `useAdjustments` | Ajustements |
|
||||||
| `useBudget` | Budget |
|
| `useBudget` | Budget |
|
||||||
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
|
| `useDashboard` | Métriques du tableau de bord |
|
||||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
|
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
|
||||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||||
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
||||||
|
|
@ -350,7 +331,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
||||||
|
|
||||||
| Route | Page | Description |
|
| Route | Page | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
|
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
|
||||||
| `/import` | `ImportPage` | Assistant d'import CSV |
|
| `/import` | `ImportPage` | Assistant d'import CSV |
|
||||||
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
||||||
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
||||||
|
|
@ -425,7 +406,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
|
||||||
| [0009](adr/0009-proxy-price-fetching-via-maximus-api.md) | Proxy price-fetching via maximus-api | 2025-01-01 | Accepted |
|
| [0009](adr/0009-proxy-price-fetching-via-maximus-api.md) | Proxy price-fetching via maximus-api | 2025-01-01 | Accepted |
|
||||||
| [0010](adr/0010-fk-restrict-balance-transfers.md) | FK RESTRICT sur balance_account_transfers | 2025-01-01 | Accepted |
|
| [0010](adr/0010-fk-restrict-balance-transfers.md) | FK RESTRICT sur balance_account_transfers | 2025-01-01 | Accepted |
|
||||||
| [0011](adr/0011-providers-best-effort-yahoo.md) | Providers best-effort Yahoo | 2026-04-26 | Accepted |
|
| [0011](adr/0011-providers-best-effort-yahoo.md) | Providers best-effort Yahoo | 2026-04-26 | Accepted |
|
||||||
| [0012](adr/0012-balance-two-level-model.md) | Modèle à deux niveaux pour le Bilan (véhicules × compositions) | 2026-05-01 | Rejected |
|
| [0012](adr/0012-balance-two-level-model.md) | Modèle à deux niveaux pour le Bilan (véhicules × compositions) | 2026-05-01 | Proposed |
|
||||||
| [0013](adr/0013-stocks-provider-evaluation.md) | Évaluation provider stocks : Alpha Vantage retenu comme cible | 2026-05-09 | Accepted |
|
|
||||||
| [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted |
|
|
||||||
| [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted |
|
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
# Audit critique — Page Bilan (suivi du patrimoine)
|
|
||||||
|
|
||||||
> Date : 2026-05-31
|
|
||||||
> Méthode : revue à deux experts (CPA / planificateur financier québécois + designer produit fintech patrimoniale), sur cartographie du code réel, suivie d'une synthèse priorisée.
|
|
||||||
> Calibrage : **les deux personas en progressif** — simple par défaut (grand public, valeurs agrégées), détail par titre en option (investisseur actif).
|
|
||||||
> Dimensions retenues : **modèle de données** + **UX de saisie & suivi**. Hors-périmètre : exactitude des calculs (Modified Dietz), complétude (dividendes, multi-devise, allocation cible) — mentionnés seulement quand ils sont un prérequis.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
Le socle est propre et honnête **pour le grand public en mode agrégé** : invariants SQL rigoureux (CHECK `kind`, UNIQUE date, FK RESTRICT), onboarding 2-step, starter accounts, saisie « 1 compte = 1 montant », pré-remplissage. Mais le **« progressif » est cassé en son centre**, et les deux experts pointent **la même cause unique** : le modèle est *plat* — une seule « catégorie » encode à la fois le **véhicule fiscal** (CELI, REER) et la **classe d'actif** (actions, crypto, encaisse). Tant que ces deux axes orthogonaux partagent un champ, la montée en puissance vers le détail par titre est soit impossible, soit punitive (rupture d'historique). Le mode détaillé est par ailleurs **inutilisable aujourd'hui** : re-saisie manuelle de tous les prix à chaque snapshot, price-fetch encore bloqué côté serveur.
|
|
||||||
|
|
||||||
La cible UX (modèle *enveloppe → positions*, façon Sharesight / Kubera) et le chemin CPA (migration **additive**, pas le big-bang de l'ADR 0012) sont **le même plan vu sous deux angles**. Il existe une trajectoire à faible risque.
|
|
||||||
|
|
||||||
## Diagnostic racine : le modèle plat
|
|
||||||
|
|
||||||
Exemple concret : un **CELI de courtage** contenant 30 actions AAPL + 5 000 $ d'encaisse. Aujourd'hui, deux choix, tous deux faux :
|
|
||||||
|
|
||||||
- compte `simple` sous `tfsa` → le titre, sa quantité et son rendement disparaissent ;
|
|
||||||
- compte `priced` sous `stock` → l'abri CELI disparaît du modèle (« combien dans mon CELI ? » devient insoluble).
|
|
||||||
|
|
||||||
Conséquences en cascade : agrégation par titre cross-véhicule impossible (« combien de VFV au total, tous comptes confondus ? »), empilé « par catégorie » qui mélange enveloppes et classes d'actif, et surtout **bascule agrégé → détaillé qui casse la courbe** (le `kind` simple/priced est figé sur la catégorie, pas sur le compte).
|
|
||||||
|
|
||||||
> Même en implémentant l'ADR 0012 tel quel, le triplet `(véhicule, composition, valeur)` garde la *composition* au niveau **classe d'actif**, pas **titre**. On déplacerait le mur sans débloquer le détail par titre individuel — l'ADR 0012 résout le mauvais grain.
|
|
||||||
|
|
||||||
## Findings — matrice sévérité × effort
|
|
||||||
|
|
||||||
Effort : **S** = à modèle constant · **M** = additif (colonnes/tables) · **L** = migration structurante. ✓✓ = relevé par les deux experts.
|
|
||||||
|
|
||||||
| # | Finding | Sévérité | Effort | Persona | Source |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| **A** | Modèle plat fusionne véhicule fiscal × classe d'actif (**la racine**) | Structurel | M→L | Les deux | ✓✓ |
|
|
||||||
| **B** | Bascule agrégé → détaillé casse l'historique (`kind` figé sur la catégorie) | Bloquant | M→L | Les deux | ✓✓ |
|
|
||||||
| **C** | Re-saisie manuelle de **tous** les prix chaque mois (Pré-remplir laisse le prix vide) | Majeur | M | Investisseur | UX |
|
|
||||||
| **D** | PRU / coût d'acquisition absent → impossible de distinguer apport vs gain latent | Majeur | M | Investisseur | CPA (+UX) |
|
|
||||||
| **E** | Symbole = texte libre, non normalisé → aucune agrégation par titre | Majeur | M | Investisseur | CPA |
|
|
||||||
| **F** | Tableau 5 colonnes de rendement : bruit anxiogène (grand public), incomplet (investisseur) | Majeur | M | Les deux | UX |
|
|
||||||
| **G** | Empilé « par catégorie » illisible (axe bâtard véhicule/actif) | Majeur | M | Les deux | UX |
|
|
||||||
| **H** | Onboarding muet sur agrégé vs détaillé ; pas de preset investisseur | Majeur | S | Les deux | UX |
|
|
||||||
| **I** | Édition de catégorie via `window.prompt()` **écrase `i18n_key` → casse le bilingue** | Mineur *(vrai bug)* | S | Les deux | UX |
|
|
||||||
| **J** | Terminologie : « catégorie » (collision avec les transactions), « snapshot » (jargon), « encaisse »/« fonds commun » | Mineur | S | Grand public | CPA |
|
|
||||||
| **K** | Symbole obligatoire pour `priced` (friction sans bénéfice tant que price-fetch bloqué) | Mineur | S | Investisseur | UX |
|
|
||||||
| **L** | Liaison de transferts 100 % manuelle, sans suggestion par montant/libellé | Mineur | M | Investisseur | UX |
|
|
||||||
| **M** | Date de snapshot immutable (corriger = supprimer + tout re-saisir) | Mineur | S | Les deux | UX |
|
|
||||||
| **N** | Devise portée par le compte alors qu'elle est une propriété du titre (dette future) | Mineur | S | Investisseur | CPA |
|
|
||||||
|
|
||||||
**Incohérence de données à acter (bug latent)** : `updateBalanceAccount` autorise à repointer un compte `simple` → `priced` **sans toucher les lignes de snapshot historiques** ; on se retrouve avec des lignes scalaires sous un compte désormais `priced` (`src/services/balance.service.ts:340`, classement par `category_kind` au chargement `src/hooks/useSnapshotEditor.ts:289`).
|
|
||||||
|
|
||||||
## Détail des findings structurels et majeurs
|
|
||||||
|
|
||||||
### A — Le modèle plat fusionne véhicule fiscal et classe d'actif (racine)
|
|
||||||
|
|
||||||
`balance_categories` encode au même niveau le véhicule (`tfsa`, `rrsp`) et la classe d'actif (`stock`, `crypto`, `cash`, `fund`). Cas réels québécois mal représentés : actions dans un CELI ; liquidités dans un compte de courtage ; même FNB (VFV) dans REER + non-enregistré (impossible d'agréger « combien de VFV au total ») ; **CELIAPP, REEE, CRI/FERR absents** des seeds. La séparation des deux axes est le prérequis de presque tout le reste.
|
|
||||||
|
|
||||||
### B — La bascule agrégé → détaillé casse l'historique
|
|
||||||
|
|
||||||
Le `kind` (`simple`/`priced`) est porté par la **catégorie**, pas par le compte, et `updateBalanceCategory` interdit d'en changer (`balance.service.ts:152`). Un utilisateur qui suit « CELI = 50 000 $ » puis veut détailler ses titres doit archiver l'ancien compte et en créer un nouveau → la série temporelle se scinde, la courbe rompt, le rendement « depuis création » repart de zéro. C'est le pire moment pour perdre l'historique (l'utilisateur monte justement en sophistication).
|
|
||||||
|
|
||||||
### C — Re-saisie manuelle de tous les prix à chaque snapshot
|
|
||||||
|
|
||||||
Sur `/balance/snapshot`, chaque ligne `priced` demande quantité × prix unitaire à la main (`SnapshotLineRow.tsx:104-165`). Le price-fetch est premium **et** encore bloqué serveur → 100 % manuel. Pire : « Pré-remplir » copie la quantité mais **laisse le prix vide** par design (`useSnapshotEditor.ts:397-405`). Pour 10 titres, c'est 10 prix à retrouver et re-saisir chaque mois — point de bascule où l'investisseur abandonne. Levier le plus aligné privacy-first/desktop : **import CSV de cours local** (pattern Portfolio Performance), indépendant du premium ; et autoriser la saisie de la valeur directe en mode `priced` (pattern Portfolio Performance : cours OU valeur).
|
|
||||||
|
|
||||||
### D — Coût d'acquisition (PBR / PRU) absent du modèle
|
|
||||||
|
|
||||||
`balance_snapshot_lines` ne porte que `quantity`, `unit_price` (prix de marché) et `value`. Aucun coût d'acquisition. Au niveau du modèle, on ne peut donc pas distinguer **apport vs gain latent** pour une position — exactement ce qu'un investisseur attend du détail par titre (« j'ai mis 8 000 $, ça vaut 11 000 $, +3 000 $ latent »). Pertinence fiscale québécoise forte (PBR = base du gain en capital imposable en non-enregistré). Recommandation (angle modèle uniquement) : champ `book_cost` (ou `avg_cost_per_unit`) saisissable sur la position-titre, pré-rempli depuis le snapshot précédent. Sans lui, le détail par titre n'apporte rien de plus que l'agrégat.
|
|
||||||
|
|
||||||
### E — Le symbole de titre n'est pas une entité normalisée
|
|
||||||
|
|
||||||
`symbol` est un `TEXT` nullable sur `balance_accounts`, sans table de référence ni unicité. `getSnapshotTotalsByCategoryAndDate` groupe uniquement par `category.key` (`balance.service.ts:1145-1174`) — le symbole n'entre dans aucune agrégation. `AAPL`, `aapl`, `AAPL.US` sont trois titres distincts. Un compte = un seul symbole → 15 titres = 15 « comptes ». Recommandation : table `balance_securities` (`symbol` normalisé UNIQUE, `name`, `asset_type`, `currency`) référencée par la **position** (pas le compte).
|
|
||||||
|
|
||||||
### F — Tableau à 5 colonnes de rendement mal calibré
|
|
||||||
|
|
||||||
`BalanceAccountsTable.tsx` affiche Valeur | Δ% | 3M | 1A | Depuis création | Non-ajusté. Pour le grand public (montants agrégés, sans transferts liés) : warnings ambre ou colonnes identiques = bruit anxiogène. Pour l'investisseur : manquent quantité, prix actuel, **gain/perte $**, **% du portefeuille**. La colonne « Non-ajusté » dérive de l'horizon 1A en dur (`BalanceAccountsTable.tsx:327-329`) sans le dire. Recommandation : progressive disclosure — défaut Compte | Valeur | Δ%, rendements pliés ; colonnes titre pertinentes pour les comptes `priced`.
|
|
||||||
|
|
||||||
### G — Empilé « par catégorie » illisible
|
|
||||||
|
|
||||||
L'empilé (`BalanceEvolutionChart`) groupe par catégorie, qui est soit un véhicule soit une classe d'actif, jamais les deux (seed `consolidated_schema.sql:266-272`). L'histoire racontée est bâtarde : ni « répartition par classe d'actif », ni « répartition par enveloppe fiscale ». Recommandation : deux axes de groupement distincts (toggle *par enveloppe* / *par classe d'actif*), débloqués par la séparation des axes (A).
|
|
||||||
|
|
||||||
### H — Onboarding muet sur le choix agrégé vs détaillé
|
|
||||||
|
|
||||||
Les 4 starter accounts sont tous `simple` (`balance.service.ts:449`) ; aucun n'introduit le suivi par titre. L'investisseur actif doit deviner le chemin (catégorie `priced` → `asset_type` → symbole → snapshot : 4 écrans + notions techniques). Recommandation : question de calibrage à l'entrée (pattern Empower/Snowball : « suivez-vous des titres individuels ? ») avec deux presets (Simple / Investisseur).
|
|
||||||
|
|
||||||
## Trajectoire recommandée (synthèse des deux experts)
|
|
||||||
|
|
||||||
Une ligne directrice, en **trois temps additifs** — pas de big-bang, les comptes simples existants ne bougent jamais.
|
|
||||||
|
|
||||||
**Étape 0 — Quick wins (effort S, indépendants, livrables tout de suite)**
|
|
||||||
`I` (corriger le bug i18n du renommage — prioritaire : casse une promesse FR/EN du projet), `J` (lexique : « catégorie » → « type/nature », gloser « snapshot », « encaisse »/« fonds » → « liquidités »/« fonds/FNB »), `K` (symbole optionnel), `M` (déplacer une date par `UPDATE` plutôt que delete+recreate). Aucun impact schéma structurant.
|
|
||||||
|
|
||||||
**Étape 1 — Séparer l'axe véhicule (effort M, migration additive)**
|
|
||||||
Ajouter `balance_accounts.vehicle_type` (contrainte incluant **CELIAPP, REEE, CRI/FERR**), backfillé depuis `category.key`. Reclasser `balance_categories` en **pure classe d'actif**. → débloque « combien dans mon CELI » indépendamment de l'actif, assainit l'empilé (`G`) avec un toggle par enveloppe / par classe d'actif, et permet la progressive disclosure du tableau (`F`). Migration purement additive.
|
|
||||||
|
|
||||||
**Étape 2 — Détail par titre sans perte d'historique (effort M→L, quand le besoin est confirmé)**
|
|
||||||
`balance_securities` (titre normalisé, devise → `N`, asset_type) + `balance_account_holdings` (positions par titre sous un compte, avec **`book_cost`** → `D`, `E`), et **migrer le `kind` de la catégorie vers le compte** → bascule « CELI agrégé → CELI détaillé » continue (`B`), avec un assistant UX « détailler ce compte en titres » qui conserve la valeur agrégée comme historique. En parallèle, traiter `C` (import CSV de cours local).
|
|
||||||
|
|
||||||
## Recommandation sur l'ADR 0012
|
|
||||||
|
|
||||||
**Ne pas l'implémenter tel quel.** Le faire passer de `Proposed` à `Superseded` au profit d'un nouvel ADR « véhicule = attribut du compte + positions optionnelles par titre ». Il visait les bons groupements (`GROUP BY véhicule`, `GROUP BY classe d'actif`) mais avec une grille 2D imposée à tous et au mauvais grain (classe, pas titre).
|
|
||||||
|
|
||||||
## Recommandation centrale
|
|
||||||
|
|
||||||
Séparer enveloppe fiscale et classe d'actif dans le modèle, puis livrer le parcours « détailler un compte agrégé en titres » qui en découle (A → B). C'est l'unique levier qui débloque le « progressif » pour les deux personas : commencer agrégé puis détailler sans perdre l'historique ni bricoler des catégories. Tout le reste (import de prix, calibrage d'onboarding, lexique) reste cosmétique tant que ce mur central existe — mais l'Étape 0 se livre indépendamment, dès maintenant.
|
|
||||||
|
|
||||||
## Annexe — fichiers de référence
|
|
||||||
|
|
||||||
- Schéma : `src-tauri/src/database/balance_schema.sql` (v9), migrations v9-v11 `src-tauri/src/lib.rs`, seeds `src-tauri/src/database/consolidated_schema.sql` (l. 185-296)
|
|
||||||
- Service : `src/services/balance.service.ts` (`updateBalanceCategory` interdit le changement de `kind` l.152 ; `updateBalanceAccount` l.340 ; `STARTER_ACCOUNTS` l.449 ; agrégation par `category.key` l.1145-1174)
|
|
||||||
- Hooks : `src/hooks/useSnapshotEditor.ts` (Pré-remplir laisse le prix vide l.397), `src/hooks/useBalanceOverview.ts`
|
|
||||||
- Composants : `src/components/balance/{SnapshotLineRow,BalanceAccountsTable,BalanceEvolutionChart,AccountForm,StarterAccountsModal,BalanceOnboardingCard}.tsx`
|
|
||||||
- Pages : `src/pages/{BalancePage,AccountsPage,SnapshotEditPage}.tsx` (édition catégorie via `window.prompt` `AccountsPage.tsx:411`)
|
|
||||||
- Types : `src/shared/types/index.ts` (l. 559-704)
|
|
||||||
- ADR challengé : `docs/adr/0012-balance-two-level-model.md` ; contexte : 0008, 0010, 0011, 0013
|
|
||||||
- Guide : `docs/guide-utilisateur.md` (l. 358-417) ; i18n : `src/i18n/locales/{fr,en}.json` clés `balance.*`
|
|
||||||
|
|
@ -357,17 +357,7 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
|
||||||
|
|
||||||
## 10. Bilan
|
## 10. Bilan
|
||||||
|
|
||||||
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* (relevé daté de votre patrimoine) de l'ensemble de vos comptes, vous suivez leur évolution dans le temps, et vous calculez le **vrai rendement** de chaque compte d'investissement en liant les transferts (apports / retraits) aux comptes correspondants.
|
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), vous suivez leur évolution dans le temps, et vous calculez le **vrai rendement** de chaque compte d'investissement en liant les transferts (apports / retraits) aux comptes correspondants.
|
||||||
|
|
||||||
Deux notions distinctes structurent le Bilan :
|
|
||||||
- la **classe d'actif** (le *type* du compte) — Liquidités, Fonds / FNB, Actions, Crypto, Autres ;
|
|
||||||
- l'**enveloppe fiscale** (le *véhicule*) — Non-enregistré, CELI, REER, FERR, CELIAPP, REEE.
|
|
||||||
|
|
||||||
Ce sont deux axes indépendants : un même CELI peut contenir des actions, des fonds et des liquidités. La classe d'actif est portée par le type du compte ; l'enveloppe fiscale est un attribut optionnel du compte. Vous pouvez ainsi lire votre patrimoine *par classe d'actif* OU *par enveloppe fiscale* (voir le graphique d'évolution).
|
|
||||||
|
|
||||||
Un compte peut être saisi de deux façons :
|
|
||||||
- **en montant unique** (compte *simple*) — vous entrez directement la valeur du compte à chaque snapshot ;
|
|
||||||
- **par titre** (compte *détaillé*) — vous entrez chaque valeur mobilière du compte (quantité × cours, avec coût d'acquisition) ; la valeur du compte est la **somme** de ses positions. Voir [Détail par titre](#détail-par-titre) plus bas.
|
|
||||||
|
|
||||||
Trois pages composent le module Bilan :
|
Trois pages composent le module Bilan :
|
||||||
- `/balance` — vue d'ensemble (graphique + tableau des comptes)
|
- `/balance` — vue d'ensemble (graphique + tableau des comptes)
|
||||||
|
|
@ -378,20 +368,16 @@ L'entrée **Bilan** dans la barre latérale (icône portefeuille) donne accès
|
||||||
|
|
||||||
### Fonctionnalités
|
### Fonctionnalités
|
||||||
|
|
||||||
- 5 types standard pré-installés, qui sont des **classes d'actif** : Liquidités, Fonds / FNB, Actions, Crypto, Autres — renommables, non-supprimables (un *type* regroupe des comptes de même nature ; à ne pas confondre avec les catégories de transactions). Les enveloppes fiscales (CELI, REER…) ne sont plus des types : ce sont désormais un attribut du compte (voir ci-dessous)
|
- 7 catégories standard pré-installées : Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres — renommables, non-supprimables
|
||||||
- Création de types personnalisés avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire)
|
- Création de catégories personnalisées (ex. FERR, RPDB) avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire)
|
||||||
- Comptes par type : nom, **enveloppe fiscale optionnelle** (Non-enregistré, CELI, REER, FERR, CELIAPP, REEE — ou aucune, par défaut), symbole optionnel (même pour les types cotés — il ne sert qu'à la récupération automatique des prix), devise (CAD au MVP), notes
|
- Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes
|
||||||
- Renommage d'un type sans casser le bilingue : le nom personnalisé est stocké à part, la traduction FR/EN d'origine reste intacte (et réapparaît si vous videz le nom personnalisé)
|
- Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer
|
||||||
- Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer ; la date d'un snapshot existant peut être déplacée (ses lignes sont conservées), tant qu'aucun autre snapshot n'occupe déjà la date cible
|
- Saisie groupée par catégorie ; pour les catégories `priced`, le `value` est calculé automatiquement (`quantity × unit_price`)
|
||||||
- Saisie groupée par type ; pour les types `priced`, le `value` est calculé automatiquement (`quantity × unit_price`)
|
- Bouton **Pré-remplir depuis le snapshot précédent** : copie les valeurs simples + les quantités priced (vous remplissez juste les nouveaux prix)
|
||||||
- **Comptes détaillés (par titre)** : un compte peut contenir plusieurs valeurs mobilières — chaque titre a sa ligne (symbole, quantité, cours, valeur, coût d'acquisition, gain latent). La valeur du compte est la somme de ses titres. Le sélecteur de titre auto-complète sur vos titres existants et permet d'en créer un (symbole + classe d'actif Action/Crypto)
|
|
||||||
- **Gain latent** par titre et agrégé (par compte, par classe d'actif, par enveloppe) : `valeur − coût d'acquisition`, en dollars et en %. Drill-down par titre dans le tableau des comptes. Une position sans coût d'acquisition saisi affiche « N/A » et est exclue du calcul du %
|
|
||||||
- **Assistant « Détailler en titres »** : bascule un compte simple en compte détaillé à partir d'une date de bascule (pivot) ; l'historique agrégé passé reste figé en lecture seule
|
|
||||||
- Bouton **Pré-remplir depuis le snapshot précédent** : copie les valeurs simples + les quantités priced + les titres, quantités et coûts d'acquisition des comptes détaillés (vous remplissez juste les nouveaux cours ; un titre à quantité 0 est ignoré)
|
|
||||||
- Liaison de transactions existantes à un compte de bilan (modal avec filtres par période / catégorie / recherche, sens auto-proposé selon le signe)
|
- Liaison de transactions existantes à un compte de bilan (modal avec filtres par période / catégorie / recherche, sens auto-proposé selon le signe)
|
||||||
- Icône d'attribution dans la page Transactions pour les transactions liées à un transfert
|
- Icône d'attribution dans la page Transactions pour les transactions liées à un transfert
|
||||||
- Graphique d'évolution du bilan : mode courbe simple, ou aire empilée avec un sous-choix d'axe — **Par classe d'actif** (défaut) ou **Par enveloppe** (fiscale). Marqueurs verticaux pour les transferts taggés (vert = in, rouge = out)
|
- Graphique d'évolution du bilan (mode courbe simple ou aire empilée par catégorie) avec marqueurs verticaux pour les transferts taggés (vert = in, rouge = out)
|
||||||
- Tableau des comptes avec **3 colonnes de rendement Modified Dietz** (3 mois / 1 an / depuis création) + colonne rendement non-ajusté côte-à-côte. Ces colonnes sont **repliées par défaut** (un bouton les affiche / les masque, votre choix est mémorisé d'une session à l'autre)
|
- Tableau des comptes avec **3 colonnes de rendement Modified Dietz** (3 mois / 1 an / depuis création) + colonne rendement non-ajusté côte-à-côte
|
||||||
- Avertissement si le dernier snapshot remonte à plus de 60 jours
|
- Avertissement si le dernier snapshot remonte à plus de 60 jours
|
||||||
- Soft-delete des comptes (`Archiver`) : masqués des nouveaux snapshots, conservés dans l'historique
|
- Soft-delete des comptes (`Archiver`) : masqués des nouveaux snapshots, conservés dans l'historique
|
||||||
- Suppression d'un snapshot avec double-confirmation (re-saisie de la date)
|
- Suppression d'un snapshot avec double-confirmation (re-saisie de la date)
|
||||||
|
|
@ -399,16 +385,15 @@ L'entrée **Bilan** dans la barre latérale (icône portefeuille) donne accès
|
||||||
|
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
||||||
1. Allez dans `/balance/accounts` → onglet Types pour créer si besoin une classe d'actif supplémentaire en `simple` (montant direct) ou `priced` (quantité × prix unitaire). Pour renommer un type standard, double-cliquez son nom — la traduction d'origine est préservée
|
1. Allez dans `/balance/accounts` → onglet Catégories pour créer si besoin une catégorie supplémentaire (ex. "FERR" en `simple`, ou "Stocks Wealthsimple" en `priced`)
|
||||||
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "Tangerine" rattaché à Liquidités, "BTC Ledger" rattaché à Crypto avec symbole `BTC` — le symbole reste optionnel). Si le compte est logé dans une enveloppe fiscale, choisissez-la dans le menu **Enveloppe fiscale** (ex. un compte d'actions dans un CELI : type Actions + enveloppe CELI) ; laissez sur « Aucune » pour un compte courant ou un wallet
|
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "TFSA Tangerine" rattaché à CELI, "BTC Ledger" rattaché à Crypto avec symbole `BTC`)
|
||||||
3. Cliquez **+ Nouveau snapshot** depuis `/balance` pour ouvrir `/balance/snapshot` à la date du jour
|
3. Cliquez **+ Nouveau snapshot** depuis `/balance` pour ouvrir `/balance/snapshot` à la date du jour
|
||||||
4. Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée
|
4. Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée
|
||||||
5. Enregistrez. Le graphique sur `/balance` s'actualise immédiatement
|
5. Enregistrez. Le graphique sur `/balance` s'actualise immédiatement
|
||||||
6. Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions du compte → **Lier transferts** → cochez les transactions qui correspondent à des apports / retraits (un dépôt CELI, un achat d'actions, etc.). Le sens (in/out) est proposé automatiquement selon le signe de la transaction
|
6. Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions du compte → **Lier transferts** → cochez les transactions qui correspondent à des apports / retraits (un dépôt CELI, un achat d'actions, etc.). Le sens (in/out) est proposé automatiquement selon le signe de la transaction
|
||||||
7. Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création. Le rendement non-ajusté à droite vous permet de comparer "valeur du compte" et "vraie performance"
|
7. Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création. Le rendement non-ajusté à droite vous permet de comparer "valeur du compte" et "vraie performance"
|
||||||
8. Pour suivre un compte **titre par titre**, soit créez-le détaillé, soit ouvrez son menu d'actions → **Détailler en titres** (assistant) ; aux snapshots suivants, ajoutez chaque valeur mobilière avec sa quantité, son cours et son coût d'acquisition. Voir [Détail par titre](#détail-par-titre)
|
8. Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)
|
||||||
9. Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition. Vous pouvez aussi y corriger la date : changez-la puis enregistrez, le snapshot est déplacé avec ses lignes (un message s'affiche si la date cible est déjà prise par un autre snapshot)
|
9. Pour supprimer un snapshot, cliquez **Supprimer** dans son éditeur et re-saisissez la date pour confirmer
|
||||||
10. Pour supprimer un snapshot, cliquez **Supprimer** dans son éditeur et re-saisissez la date pour confirmer
|
|
||||||
|
|
||||||
### Lecture des rendements multi-horizons
|
### Lecture des rendements multi-horizons
|
||||||
|
|
||||||
|
|
@ -422,28 +407,6 @@ Avertissements affichés :
|
||||||
- *Aucun transfert lié* — le rendement est calculé sans apports identifiés (équivaut au non-ajusté)
|
- *Aucun transfert lié* — le rendement est calculé sans apports identifiés (équivaut au non-ajusté)
|
||||||
- *Performance non significative* — le compte a été vidé puis rechargé, le calcul Modified Dietz produit un résultat instable
|
- *Performance non significative* — le compte a été vidé puis rechargé, le calcul Modified Dietz produit un résultat instable
|
||||||
|
|
||||||
### Détail par titre
|
|
||||||
|
|
||||||
Un compte d'investissement peut être suivi **titre par titre** plutôt qu'en montant unique. Chaque valeur mobilière (action, FNB, crypto) devient une ligne avec sa quantité, son cours, sa valeur (`quantité × cours`) et son **coût d'acquisition** (le prix payé). La valeur du compte est la **somme** de ses titres.
|
|
||||||
|
|
||||||
**Saisir un compte détaillé, titre par titre.** Dans l'éditeur de snapshot, un compte détaillé se déplie en sous-lignes :
|
|
||||||
1. Cliquez **Ajouter un titre**, puis tapez un symbole (ex. `AAPL`, `BTC`) dans le sélecteur. S'il existe déjà, choisissez-le ; sinon, créez-le en choisissant sa classe d'actif (Action ou Crypto)
|
|
||||||
2. Saisissez la **quantité** et le **cours** (ou utilisez le bouton de récupération automatique des prix si vous y avez droit) — la valeur de la ligne se calcule
|
|
||||||
3. Saisissez le **coût d'acquisition** de la position (le total payé) — il alimente le gain latent ; laissez-le vide si vous ne le suivez pas (le gain s'affichera « N/A »)
|
|
||||||
4. Répétez pour chaque titre. La valeur du compte affichée en bas = la somme des positions
|
|
||||||
5. Enregistrez. Au prochain snapshot, **Pré-remplir** rapporte vos titres, quantités et coûts d'acquisition — vous n'avez qu'à rafraîchir les cours
|
|
||||||
|
|
||||||
**Détailler un compte agrégé existant (l'assistant).** Si un compte était suivi en montant unique et que vous voulez désormais le suivre par titre, ouvrez son menu d'actions dans le tableau des comptes → **Détailler en titres**. L'assistant :
|
|
||||||
- passe le compte en **détaillé** et fixe la **date de bascule (pivot)** à aujourd'hui ;
|
|
||||||
- **fige l'historique agrégé passé** en lecture seule : vos anciens snapshots gardent leur montant unique, rien n'est réécrit ni recalculé ;
|
|
||||||
- ne vous demande **pas** de saisir les titres tout de suite — vous les saisirez à votre **prochain snapshot** normal.
|
|
||||||
|
|
||||||
⚠️ C'est une action **à sens unique** : une fois qu'au moins un titre est saisi, le compte ne peut plus revenir en saisie agrégée (sinon le détail serait perdu).
|
|
||||||
|
|
||||||
**Lire le gain latent.** Dans le tableau des comptes, un compte détaillé peut être déplié pour voir chaque titre avec sa valeur et son gain latent (`valeur − coût d'acquisition`, en $ et en %). Le gain latent est aussi agrégé **par classe d'actif** et **par enveloppe**. Le gain latent répond à « combien ai-je gagné depuis l'achat ? » — c'est distinct du **rendement Modified Dietz** (3M / 1A / depuis création), qui mesure la performance du compte en tenant compte du *timing* de vos apports. Les deux coexistent : le rendement reste au niveau du compte, inchangé.
|
|
||||||
|
|
||||||
> Le coût d'acquisition est **saisi**, pas déduit de vos transactions. Pré-rempli d'un snapshot à l'autre, il est correct tant qu'aucun achat ni vente n'a eu lieu sur le titre — après un achat/vente, ajustez-le manuellement pour que le gain latent reste juste.
|
|
||||||
|
|
||||||
### Que faire si je supprime une transaction liée ?
|
### Que faire si je supprime une transaction liée ?
|
||||||
|
|
||||||
C'est intentionnellement bloqué : si vous tentez de supprimer une transaction qui est liée à un compte de bilan, vous voyez le message **"Cette transaction est liée au compte de bilan _<nom>_"** avec un lien direct vers le compte. Ouvrez le compte → Lier transferts → décochez la transaction → revenez la supprimer. Cette friction préserve la reproductibilité de vos rendements passés (un rendement calculé hier ne peut pas changer aujourd'hui à cause d'une suppression silencieuse).
|
C'est intentionnellement bloqué : si vous tentez de supprimer une transaction qui est liée à un compte de bilan, vous voyez le message **"Cette transaction est liée au compte de bilan _<nom>_"** avec un lien direct vers le compte. Ouvrez le compte → Lier transferts → décochez la transaction → revenez la supprimer. Cette friction préserve la reproductibilité de vos rendements passés (un rendement calculé hier ne peut pas changer aujourd'hui à cause d'une suppression silencieuse).
|
||||||
|
|
@ -452,11 +415,9 @@ C'est intentionnellement bloqué : si vous tentez de supprimer une transaction q
|
||||||
|
|
||||||
- Saisissez vos snapshots à un rythme régulier (mensuel ou trimestriel) — la qualité des rendements dépend directement de la régularité
|
- Saisissez vos snapshots à un rythme régulier (mensuel ou trimestriel) — la qualité des rendements dépend directement de la régularité
|
||||||
- Utilisez le bouton **Pré-remplir** : ça copie tout, vous mettez juste à jour ce qui a changé
|
- Utilisez le bouton **Pré-remplir** : ça copie tout, vous mettez juste à jour ce qui a changé
|
||||||
- Le mode **graphique empilé** raconte une histoire différente du mode ligne : il montre la composition de votre patrimoine, pas seulement son total. Basculez l'axe entre **Par classe d'actif** (combien en actions, en liquidités…) et **Par enveloppe** (combien en CELI, en REER, hors enveloppe…) pour lire les deux faces du même patrimoine
|
- Le mode **graphique empilé par catégorie** raconte une histoire différente du mode ligne : il montre la composition de votre patrimoine, pas seulement son total
|
||||||
- **Note pour les bilans historiques.** Depuis la version qui sépare classe d'actif et enveloppe fiscale, les anciens types « CELI » et « REER » sont devenus des **enveloppes**, et les comptes concernés ont été reclassés en classe d'actif **« Autres »** (en conservant leur enveloppe). L'axe « par classe d'actif » étant recalculé sur la classe **actuelle** du compte, un snapshot saisi *avant* cette migration apparaît désormais sous « Autres » sur cet axe (et non plus sous « CELI »/« REER »). C'est attendu : l'axe **« par enveloppe »**, lui, retrouve bien vos CELI / REER. Vos montants et votre historique ne changent pas — seul le regroupement d'affichage évolue
|
|
||||||
- Les marqueurs verticaux du graphique (transferts taggés) aident à lire les sauts de valeur — un saut suivi d'un marqueur vert n'est pas une "performance", c'est juste un dépôt
|
- Les marqueurs verticaux du graphique (transferts taggés) aident à lire les sauts de valeur — un saut suivi d'un marqueur vert n'est pas une "performance", c'est juste un dépôt
|
||||||
- L'avertissement "bilan pas à jour" apparaît si votre dernier snapshot remonte à plus de 60 jours — c'est le signe qu'il est temps d'en saisir un nouveau
|
- L'avertissement "bilan pas à jour" apparaît si votre dernier snapshot remonte à plus de 60 jours — c'est le signe qu'il est temps d'en saisir un nouveau
|
||||||
- **Gain latent ≠ rendement.** Le gain latent (`valeur − coût d'acquisition`) répond à « combien vaut ma plus-value depuis l'achat ? » ; le rendement Modified Dietz répond à « quelle performance, en tenant compte de *quand* j'ai investi ? ». Les deux s'affichent côte à côte sur un compte détaillé. Pensez à ajuster le coût d'acquisition après un achat ou une vente, sinon le gain latent dérive.
|
|
||||||
- (À venir Phase 5) **Récupération automatique des prix** pour les comptes Actions / Crypto via un proxy privé (premium-only). Le service interroge un serveur Maximus dédié qui anonymise votre requête (votre IP n'est jamais exposée à Yahoo / CoinGecko). La saisie manuelle reste toujours disponible.
|
- (À venir Phase 5) **Récupération automatique des prix** pour les comptes Actions / Crypto via un proxy privé (premium-only). Le service interroge un serveur Maximus dédié qui anonymise votre requête (votre IP n'est jamais exposée à Yahoo / CoinGecko). La saisie manuelle reste toujours disponible.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
426
package-lock.json
generated
426
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.13.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.13.0",
|
"version": "0.9.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.18.1",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -35,18 +35,17 @@
|
||||||
"@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.3",
|
"vite": "^6.4.1",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.29.7",
|
"@babel/helper-validator-identifier": "^7.28.5",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
|
|
@ -55,31 +54,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||||
"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.7",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.0",
|
||||||
"@babel/helper-compilation-targets": "^7.29.7",
|
"@babel/helper-compilation-targets": "^7.28.6",
|
||||||
"@babel/helper-module-transforms": "^7.29.7",
|
"@babel/helper-module-transforms": "^7.28.6",
|
||||||
"@babel/helpers": "^7.29.7",
|
"@babel/helpers": "^7.28.6",
|
||||||
"@babel/parser": "^7.29.7",
|
"@babel/parser": "^7.29.0",
|
||||||
"@babel/template": "^7.29.7",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/traverse": "^7.29.7",
|
"@babel/traverse": "^7.29.0",
|
||||||
"@babel/types": "^7.29.7",
|
"@babel/types": "^7.29.0",
|
||||||
"@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",
|
||||||
|
|
@ -96,14 +93,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.7",
|
"@babel/parser": "^7.29.0",
|
||||||
"@babel/types": "^7.29.7",
|
"@babel/types": "^7.29.0",
|
||||||
"@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"
|
||||||
|
|
@ -113,14 +109,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.29.7",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.29.7",
|
"@babel/compat-data": "^7.28.6",
|
||||||
"@babel/helper-validator-option": "^7.29.7",
|
"@babel/helper-validator-option": "^7.27.1",
|
||||||
"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"
|
||||||
|
|
@ -130,39 +125,36 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.29.7",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||||
"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.29.7",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.29.7",
|
"@babel/traverse": "^7.28.6",
|
||||||
"@babel/types": "^7.29.7"
|
"@babel/types": "^7.28.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.29.7",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.29.7",
|
"@babel/helper-module-imports": "^7.28.6",
|
||||||
"@babel/helper-validator-identifier": "^7.29.7",
|
"@babel/helper-validator-identifier": "^7.28.5",
|
||||||
"@babel/traverse": "^7.29.7"
|
"@babel/traverse": "^7.28.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -181,57 +173,52 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.29.7",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
"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.29.7",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
"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.29.7",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||||
"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.29.7",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.29.7",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/types": "^7.29.7"
|
"@babel/types": "^7.28.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.7"
|
"@babel/types": "^7.29.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
|
|
@ -279,33 +266,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/parser": "^7.29.7",
|
"@babel/parser": "^7.28.6",
|
||||||
"@babel/types": "^7.29.7"
|
"@babel/types": "^7.28.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.0",
|
||||||
"@babel/helper-globals": "^7.29.7",
|
"@babel/helper-globals": "^7.28.0",
|
||||||
"@babel/parser": "^7.29.7",
|
"@babel/parser": "^7.29.0",
|
||||||
"@babel/template": "^7.29.7",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/types": "^7.29.7",
|
"@babel/types": "^7.29.0",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -313,14 +298,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.29.7",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.29.7"
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -1980,31 +1964,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "4.1.9",
|
"@vitest/spy": "4.0.18",
|
||||||
"@vitest/utils": "4.1.9",
|
"@vitest/utils": "4.0.18",
|
||||||
"chai": "^6.2.2",
|
"chai": "^6.2.1",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.9",
|
"@vitest/spy": "4.0.18",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
|
|
@ -2013,7 +1997,7 @@
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"msw": "^2.4.9",
|
"msw": "^2.4.9",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^6.0.0 || ^7.0.0-0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"msw": {
|
"msw": {
|
||||||
|
|
@ -2025,26 +2009,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.1.9",
|
"@vitest/utils": "4.0.18",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|
@ -2052,14 +2036,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.9",
|
"@vitest/pretty-format": "4.0.18",
|
||||||
"@vitest/utils": "4.1.9",
|
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
|
|
@ -2068,9 +2051,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|
@ -2078,15 +2061,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.9",
|
"@vitest/pretty-format": "4.0.18",
|
||||||
"convert-source-map": "^2.0.0",
|
"tinyrainbow": "^3.0.3"
|
||||||
"tinyrainbow": "^3.1.0"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
|
|
@ -2103,22 +2085,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.41",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
|
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.cjs"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.4",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -2134,13 +2112,12 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.38",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001799",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
"electron-to-chromium": "^1.5.376",
|
"electron-to-chromium": "^1.5.263",
|
||||||
"node-releases": "^2.0.48",
|
"node-releases": "^2.0.27",
|
||||||
"update-browserslist-db": "^1.2.3"
|
"update-browserslist-db": "^1.2.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"browserslist": "cli.js"
|
"browserslist": "cli.js"
|
||||||
|
|
@ -2150,9 +2127,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001800",
|
"version": "1.0.30001769",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
|
||||||
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -2167,8 +2144,7 @@
|
||||||
"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",
|
||||||
|
|
@ -2198,7 +2174,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -2328,7 +2303,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -2356,11 +2330,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.384",
|
"version": "1.5.286",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||||
"integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
|
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"license": "ISC"
|
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.19.0",
|
"version": "5.19.0",
|
||||||
|
|
@ -2376,9 +2349,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.2.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -2433,7 +2406,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -2577,15 +2549,13 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -2859,7 +2829,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -2885,8 +2854,7 @@
|
||||||
"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",
|
||||||
|
|
@ -2907,14 +2875,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.50",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
|
|
@ -3070,10 +3034,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.1",
|
"version": "7.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
"set-cookie-parser": "^2.6.0"
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
|
@ -3092,12 +3055,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.1",
|
"version": "7.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
||||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.1"
|
"react-router": "7.13.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|
@ -3206,7 +3168,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -3214,8 +3175,7 @@
|
||||||
"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",
|
||||||
|
|
@ -3241,9 +3201,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "4.1.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -3305,9 +3265,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyrainbow": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "3.1.0",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -3357,7 +3317,6 @@
|
||||||
"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"
|
||||||
|
|
@ -3399,9 +3358,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -3474,31 +3433,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.9",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.9",
|
"@vitest/expect": "4.0.18",
|
||||||
"@vitest/mocker": "4.1.9",
|
"@vitest/mocker": "4.0.18",
|
||||||
"@vitest/pretty-format": "4.1.9",
|
"@vitest/pretty-format": "4.0.18",
|
||||||
"@vitest/runner": "4.1.9",
|
"@vitest/runner": "4.0.18",
|
||||||
"@vitest/snapshot": "4.1.9",
|
"@vitest/snapshot": "4.0.18",
|
||||||
"@vitest/spy": "4.1.9",
|
"@vitest/spy": "4.0.18",
|
||||||
"@vitest/utils": "4.1.9",
|
"@vitest/utils": "4.0.18",
|
||||||
"es-module-lexer": "^2.0.0",
|
"es-module-lexer": "^1.7.0",
|
||||||
"expect-type": "^1.3.0",
|
"expect-type": "^1.2.2",
|
||||||
"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": "^4.0.0-rc.1",
|
"std-env": "^3.10.0",
|
||||||
"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.1.0",
|
"tinyrainbow": "^3.0.3",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
"vite": "^6.0.0 || ^7.0.0",
|
||||||
"why-is-node-running": "^2.3.0"
|
"why-is-node-running": "^2.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -3514,15 +3473,12 @@
|
||||||
"@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.1.9",
|
"@vitest/browser-playwright": "4.0.18",
|
||||||
"@vitest/browser-preview": "4.1.9",
|
"@vitest/browser-preview": "4.0.18",
|
||||||
"@vitest/browser-webdriverio": "4.1.9",
|
"@vitest/browser-webdriverio": "4.0.18",
|
||||||
"@vitest/coverage-istanbul": "4.1.9",
|
"@vitest/ui": "4.0.18",
|
||||||
"@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": {
|
||||||
|
|
@ -3543,12 +3499,6 @@
|
||||||
"@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
|
||||||
},
|
},
|
||||||
|
|
@ -3557,9 +3507,6 @@
|
||||||
},
|
},
|
||||||
"jsdom": {
|
"jsdom": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
|
||||||
"vite": {
|
|
||||||
"optional": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3592,8 +3539,7 @@
|
||||||
"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.13.0",
|
"version": "0.9.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.18.1",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -39,12 +39,7 @@
|
||||||
"@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.3",
|
"vite": "^6.4.1",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.0.18"
|
||||||
},
|
|
||||||
"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.13.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.13.0"
|
version = "0.9.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,68 +0,0 @@
|
||||||
-- Balance sheet — détail par titre (Bilan Étape 2) — Migration v14
|
|
||||||
-- Created: 2026-06-06
|
|
||||||
-- Issue: #210 (Bilan détail #1 — schema & migrations v14/v15 + types)
|
|
||||||
--
|
|
||||||
-- Purely additive: two new tables enabling a single account to hold many
|
|
||||||
-- securities at a given snapshot date instead of one denormalized value.
|
|
||||||
-- Conventions aligned with balance_schema.sql / consolidated_schema.sql:
|
|
||||||
-- - INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
-- - REAL for monetary amounts / quantities (matches transactions.amount)
|
|
||||||
-- - snake_case
|
|
||||||
-- - FK with explicit ON DELETE policies
|
|
||||||
-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps
|
|
||||||
--
|
|
||||||
-- Design notes (spec-decisions-bilan-detail-titres.md + review caveats):
|
|
||||||
-- - balance_securities.symbol is the natural key, normalized upper/trim by
|
|
||||||
-- the service layer; COLLATE NOCASE UNIQUE prevents case-duplicates
|
|
||||||
-- ('aapl' vs 'AAPL') at the SQL level (SEC/ARCH review caveat).
|
|
||||||
-- - balance_snapshot_holdings references balance_snapshot_lines(id) rather
|
|
||||||
-- than (snapshot_id, account_id): the lines table already guarantees one
|
|
||||||
-- row per (snapshot, account) via UNIQUE(snapshot_id, account_id), so the
|
|
||||||
-- line id uniquely identifies that pair (ARCH review: keep the line FK).
|
|
||||||
-- - value is denormalized (= quantity * unit_price) so reports stay
|
|
||||||
-- reproducible without re-fetching prices — same rationale as
|
|
||||||
-- balance_snapshot_lines.value.
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- balance_securities — catalogue of investable instruments (stock | crypto)
|
|
||||||
-- =========================================================================
|
|
||||||
-- One row per security/coin the user holds in a detailed account. `symbol` is
|
|
||||||
-- stored normalized (upper/trim) with COLLATE NOCASE so duplicates differing
|
|
||||||
-- only by case are impossible. `asset_type` mirrors balance_categories so the
|
|
||||||
-- price-fetch flow can route per security.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_securities (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
symbol TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
|
||||||
name TEXT,
|
|
||||||
currency TEXT NOT NULL DEFAULT 'CAD',
|
|
||||||
asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')),
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- balance_snapshot_holdings — one row per (snapshot line, security)
|
|
||||||
-- =========================================================================
|
|
||||||
-- The per-title breakdown of a detailed account's snapshot line. CASCADE on
|
|
||||||
-- snapshot_line_id wipes holdings when the parent line is removed; RESTRICT on
|
|
||||||
-- security_id blocks deleting a security still referenced by history. `value`
|
|
||||||
-- is stored denormalized (= quantity * unit_price) for reproducible reports.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_snapshot_holdings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE,
|
|
||||||
security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT,
|
|
||||||
quantity REAL NOT NULL,
|
|
||||||
unit_price REAL NOT NULL,
|
|
||||||
value REAL NOT NULL,
|
|
||||||
book_cost REAL,
|
|
||||||
price_source TEXT,
|
|
||||||
price_fetched_at DATETIME,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE (snapshot_line_id, security_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id);
|
|
||||||
|
|
@ -197,10 +197,7 @@ CREATE TABLE IF NOT EXISTS balance_categories (
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
is_seed INTEGER NOT NULL DEFAULT 0,
|
is_seed INTEGER NOT NULL DEFAULT 0,
|
||||||
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')),
|
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto'))
|
||||||
-- User-facing override for the category label (migration v12). When set,
|
|
||||||
-- the UI shows this verbatim; otherwise it falls back to t(i18n_key).
|
|
||||||
custom_label TEXT
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||||
|
|
@ -212,16 +209,6 @@ CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
archived_at DATETIME,
|
archived_at DATETIME,
|
||||||
-- Fiscal envelope / tax shelter (migration v12). NULL = no envelope (e.g.
|
|
||||||
-- a chequing account or crypto wallet). NOT an automobile type.
|
|
||||||
vehicle_type TEXT CHECK(vehicle_type IS NULL OR vehicle_type IN ('unregistered','tfsa','rrsp','rrif','fhsa','resp')),
|
|
||||||
-- Entry mode (migration v15). 'simple' = one denormalized value per
|
|
||||||
-- snapshot line; 'detailed' = a basket of per-security holdings (see
|
|
||||||
-- balance_snapshot_holdings). Accounts under a priced category are backfilled
|
|
||||||
-- to 'detailed' below. `detailed_since` is the authoritative pivot date from
|
|
||||||
-- which detailed entry is expected — NULL until the conversion flow sets it.
|
|
||||||
kind TEXT NOT NULL DEFAULT 'simple' CHECK (kind IN ('simple','detailed')),
|
|
||||||
detailed_since DATE,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||||
|
|
@ -267,36 +254,6 @@ CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||||
UNIQUE(transaction_id, account_id)
|
UNIQUE(transaction_id, account_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Détail par titre (Bilan Étape 2, migration v14). Kept in sync with
|
|
||||||
-- `balance_holdings_schema.sql` (the source of truth applied by Migration v14
|
|
||||||
-- in lib.rs). balance_securities is the instrument catalogue (symbol normalized
|
|
||||||
-- upper/trim, COLLATE NOCASE UNIQUE); balance_snapshot_holdings is the per-title
|
|
||||||
-- breakdown of a detailed account's snapshot line.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_securities (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
symbol TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
|
||||||
name TEXT,
|
|
||||||
currency TEXT NOT NULL DEFAULT 'CAD',
|
|
||||||
asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')),
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_snapshot_holdings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE,
|
|
||||||
security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT,
|
|
||||||
quantity REAL NOT NULL,
|
|
||||||
unit_price REAL NOT NULL,
|
|
||||||
value REAL NOT NULL,
|
|
||||||
book_cost REAL,
|
|
||||||
price_source TEXT,
|
|
||||||
price_fetched_at DATETIME,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE (snapshot_line_id, security_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
||||||
|
|
@ -304,15 +261,11 @@ CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapsho
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id);
|
|
||||||
|
|
||||||
-- Asset classes only (Bilan axe véhicule, Étape 1). The former `tfsa` / `rrsp`
|
|
||||||
-- seeds were fiscal envelopes, not asset classes — they now live on the account
|
|
||||||
-- as `vehicle_type` (see balance_accounts.vehicle_type). New profiles ship with
|
|
||||||
-- exactly 5 asset classes: Liquidités, Fonds/FNB, Actions, Crypto, Autres.
|
|
||||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
|
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
|
||||||
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
|
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
|
||||||
|
('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL),
|
||||||
|
('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL),
|
||||||
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
|
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
|
||||||
('other', 'balance.category.other', 'simple', 50, 1, NULL),
|
('other', 'balance.category.other', 'simple', 50, 1, NULL),
|
||||||
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
|
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
|
||||||
|
|
@ -323,24 +276,11 @@ INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_se
|
||||||
-- balance_accounts) — once created they are indistinguishable from
|
-- balance_accounts) — once created they are indistinguishable from
|
||||||
-- user-created accounts and can be renamed/archived freely. Existing profiles
|
-- user-created accounts and can be renamed/archived freely. Existing profiles
|
||||||
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
|
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
|
||||||
--
|
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) VALUES
|
||||||
-- Bilan axe véhicule (Étape 1): the CELI / REER starters are no longer linked
|
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1),
|
||||||
-- to a `tfsa` / `rrsp` category (those seeds are gone). They now attach to the
|
((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1),
|
||||||
-- `other` asset class and carry the envelope in `vehicle_type`. Linking them to
|
((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1),
|
||||||
-- a deleted key would make the subselect return NULL → NOT NULL violation →
|
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1);
|
||||||
-- broken new-profile init, so the asset class MUST resolve to `other`.
|
|
||||||
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type) VALUES
|
|
||||||
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1, NULL),
|
|
||||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'CELI', 'CAD', 1, 'tfsa'),
|
|
||||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'REER', 'CAD', 1, 'rrsp'),
|
|
||||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1, NULL);
|
|
||||||
|
|
||||||
-- Détail par titre (migration v15 mirror): accounts under a priced category are
|
|
||||||
-- detailed-entry by default. The 4 starters above all sit under simple asset
|
|
||||||
-- classes (cash / other), so this is currently a no-op — kept at parity with the
|
|
||||||
-- v15 backfill so any future priced starter is stamped correctly.
|
|
||||||
UPDATE balance_accounts SET kind = 'detailed'
|
|
||||||
WHERE balance_category_id IN (SELECT id FROM balance_categories WHERE kind = 'priced');
|
|
||||||
|
|
||||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,3 @@ pub const SCHEMA: &str = include_str!("schema.sql");
|
||||||
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||||
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||||
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");
|
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");
|
||||||
pub const BALANCE_HOLDINGS_SCHEMA: &str = include_str!("balance_holdings_schema.sql");
|
|
||||||
|
|
|
||||||
2119
src-tauri/src/lib.rs
2119
src-tauri/src/lib.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Resultat",
|
"productName": "Simpl Resultat",
|
||||||
"version": "0.13.0",
|
"version": "0.9.0",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,9 @@
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("../services/db", () => {
|
vi.mock("../services/db", () => ({
|
||||||
const getDb = vi.fn();
|
getDb: vi.fn(),
|
||||||
return {
|
}));
|
||||||
getDb,
|
|
||||||
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
|
|
@ -58,9 +54,6 @@ import {
|
||||||
unlinkTransfer,
|
unlinkTransfer,
|
||||||
listAccountTransfers,
|
listAccountTransfers,
|
||||||
computeAccountReturn,
|
computeAccountReturn,
|
||||||
saveSnapshotAtomic,
|
|
||||||
deleteSnapshot,
|
|
||||||
getSnapshotTotalsByDate,
|
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
PRICED_VALUE_TOLERANCE,
|
PRICED_VALUE_TOLERANCE,
|
||||||
} from "../services/balance.service";
|
} from "../services/balance.service";
|
||||||
|
|
@ -418,8 +411,8 @@ describe("integration — currency lock (CAD only)", () => {
|
||||||
c.sql.includes("INSERT INTO balance_accounts")
|
c.sql.includes("INSERT INTO balance_accounts")
|
||||||
);
|
);
|
||||||
expect(insertCall).toBeDefined();
|
expect(insertCall).toBeDefined();
|
||||||
// [category_id, name, symbol, currency, notes, vehicle_type] (#202)
|
// [category_id, name, symbol, currency, notes]
|
||||||
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null, null]);
|
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
|
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
|
||||||
|
|
@ -580,394 +573,3 @@ describe("integration — computeAccountReturn validates dates client-side", ()
|
||||||
expect(out.value_start).toBeNull();
|
expect(out.value_start).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 5. Per-security detail (Étape 2, #217) — detailed snapshot save, rollback,
|
|
||||||
// date-move with holdings, and the golden-value aggregation invariant.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// The unit-level holdings/securities mechanics are covered exhaustively in
|
|
||||||
// balance.service.test.ts (#212). Here we assert the END-TO-END service
|
|
||||||
// behaviour at the integration layer and — critically — the REGRESSION
|
|
||||||
// invariant that makes detailed accounts safe: a detailed account whose
|
|
||||||
// holdings sum to V writes an aggregated line worth exactly V, so every
|
|
||||||
// SUM(value) aggregator sees it identically to a simple account worth V.
|
|
||||||
//
|
|
||||||
// (The real-SQLite proof of that equality lives in lib.rs
|
|
||||||
// `regression_detailed_account_totals_equal_simple_account_totals`, where
|
|
||||||
// SUM() actually runs. Here we prove the TS half: the stored line value is
|
|
||||||
// the rounded-cent SUM, which is the only number the aggregator reads.)
|
|
||||||
|
|
||||||
describe("integration — detailed snapshot save end-to-end (#217)", () => {
|
|
||||||
it("writes the aggregated line + securities + holdings in ONE BEGIN/COMMIT", async () => {
|
|
||||||
// New snapshot, one detailed account: AAPL (2×50=100) + MSFT (1×200=200)
|
|
||||||
// ⇒ aggregated line value 300, two holdings, all atomic.
|
|
||||||
queueSelects([]); // dup-check: date free
|
|
||||||
// findOrCreateSecurity AAPL then MSFT lookups (post-upsert SELECT).
|
|
||||||
queueSelects([
|
|
||||||
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
|
||||||
]);
|
|
||||||
queueSelects([
|
|
||||||
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
|
||||||
]);
|
|
||||||
queueExecutes(
|
|
||||||
{ rowsAffected: 0 }, // BEGIN
|
|
||||||
{ lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
|
|
||||||
{ rowsAffected: 0 }, // DELETE lines
|
|
||||||
{ lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
|
|
||||||
{ rowsAffected: 0 }, // DELETE holdings for line 500
|
|
||||||
{ rowsAffected: 1 }, // UPSERT security AAPL
|
|
||||||
{ rowsAffected: 1 }, // INSERT holding AAPL
|
|
||||||
{ rowsAffected: 1 }, // UPSERT security MSFT
|
|
||||||
{ rowsAffected: 1 }, // INSERT holding MSFT
|
|
||||||
{ rowsAffected: 1 }, // UPDATE updated_at
|
|
||||||
{ rowsAffected: 0 } // COMMIT
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await saveSnapshotAtomic({
|
|
||||||
existingSnapshotId: null,
|
|
||||||
snapshot_date: "2026-05-30",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
value: 300,
|
|
||||||
holdings: [
|
|
||||||
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 },
|
|
||||||
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(res.snapshotId).toBe(42);
|
|
||||||
|
|
||||||
const execCalls = fake.calls.filter(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
!c.sql.includes("SELECT") // executes only
|
|
||||||
);
|
|
||||||
// First write is BEGIN, last is COMMIT, no ROLLBACK anywhere.
|
|
||||||
expect(execCalls[0].sql).toBe("BEGIN");
|
|
||||||
expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
|
|
||||||
expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);
|
|
||||||
|
|
||||||
// CRITICAL invariant: the aggregated line is stored with NULL qty/price and
|
|
||||||
// value = rounded-cent SUM(holdings) = 300 — the exact number the
|
|
||||||
// aggregators read. This is the TS-side golden-value guarantee.
|
|
||||||
const lineInsert = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
|
||||||
);
|
|
||||||
expect(lineInsert!.params).toEqual([42, 7, 300]);
|
|
||||||
|
|
||||||
// Both holdings reference the captured line id (500).
|
|
||||||
const holdingInserts = fake.calls.filter(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_snapshot_holdings")
|
|
||||||
);
|
|
||||||
expect(holdingInserts).toHaveLength(2);
|
|
||||||
expect(holdingInserts[0].params?.[0]).toBe(500);
|
|
||||||
expect(holdingInserts[1].params?.[0]).toBe(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => {
|
|
||||||
queueSelects([]); // dup-check
|
|
||||||
queueSelects([
|
|
||||||
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
|
||||||
]);
|
|
||||||
// BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings,
|
|
||||||
// UPSERT security, then the holding INSERT REJECTS, then ROLLBACK.
|
|
||||||
fake.executeQueue.push({ rowsAffected: 0 }); // BEGIN
|
|
||||||
fake.executeQueue.push({ lastInsertId: 42, rowsAffected: 1 }); // INSERT snapshot
|
|
||||||
fake.executeQueue.push({ rowsAffected: 0 }); // DELETE lines
|
|
||||||
fake.executeQueue.push({ lastInsertId: 500, rowsAffected: 1 }); // INSERT line
|
|
||||||
fake.executeQueue.push({ rowsAffected: 0 }); // DELETE holdings
|
|
||||||
fake.executeQueue.push({ rowsAffected: 1 }); // UPSERT security
|
|
||||||
// Make the NEXT execute (the holding INSERT) reject, then allow ROLLBACK.
|
|
||||||
let failed = false;
|
|
||||||
fake.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
|
|
||||||
fake.calls.push({ sql, params });
|
|
||||||
if (!failed && sql.includes("INSERT INTO balance_snapshot_holdings")) {
|
|
||||||
failed = true;
|
|
||||||
throw new Error("holding FK violation");
|
|
||||||
}
|
|
||||||
if (fake.executeQueue.length === 0) {
|
|
||||||
return { rowsAffected: 1, lastInsertId: fake.calls.length };
|
|
||||||
}
|
|
||||||
return fake.executeQueue.shift()!;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
saveSnapshotAtomic({
|
|
||||||
existingSnapshotId: null,
|
|
||||||
snapshot_date: "2026-05-30",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
value: 100,
|
|
||||||
holdings: [
|
|
||||||
{ symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
).rejects.toThrow("holding FK violation");
|
|
||||||
|
|
||||||
// ROLLBACK was the last write; COMMIT never happened ⇒ no partial state.
|
|
||||||
const execCalls = fake.calls.filter(
|
|
||||||
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
|
|
||||||
);
|
|
||||||
expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
|
|
||||||
expect(execCalls.some((c) => c.sql === "COMMIT")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves a detailed snapshot's date — line AND holdings move together (#200)", async () => {
|
|
||||||
// Edit-mode move: the date UPDATE + the line/holdings rewrite happen in the
|
|
||||||
// SAME transaction, so the holdings follow their line to the new date.
|
|
||||||
queueSelects([]); // collision check: target date free
|
|
||||||
queueSelects([
|
|
||||||
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
|
||||||
]);
|
|
||||||
queueExecutes(
|
|
||||||
{ rowsAffected: 0 }, // BEGIN
|
|
||||||
{ rowsAffected: 1 }, // UPDATE snapshot_date
|
|
||||||
{ rowsAffected: 0 }, // DELETE lines (cascades old holdings)
|
|
||||||
{ lastInsertId: 800, rowsAffected: 1 }, // INSERT aggregated line at new date
|
|
||||||
{ rowsAffected: 0 }, // DELETE holdings for line 800
|
|
||||||
{ rowsAffected: 1 }, // UPSERT security AAPL
|
|
||||||
{ rowsAffected: 1 }, // INSERT holding AAPL
|
|
||||||
{ rowsAffected: 1 }, // UPDATE updated_at
|
|
||||||
{ rowsAffected: 0 } // COMMIT
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await saveSnapshotAtomic({
|
|
||||||
existingSnapshotId: 5,
|
|
||||||
snapshot_date: "2026-04-15",
|
|
||||||
moveToDate: "2026-05-20",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
value: 100,
|
|
||||||
holdings: [
|
|
||||||
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100, book_cost: 90 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(res.snapshotId).toBe(5);
|
|
||||||
|
|
||||||
// The date UPDATE precedes the line rewrite (so a collision rolls back both).
|
|
||||||
const dateUpdate = fake.calls.find(
|
|
||||||
(c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date = $1")
|
|
||||||
);
|
|
||||||
expect(dateUpdate!.params).toEqual(["2026-05-20", 5]);
|
|
||||||
|
|
||||||
// The holding is re-inserted alongside the moved line (referencing line 800).
|
|
||||||
const holdingInsert = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_snapshot_holdings")
|
|
||||||
);
|
|
||||||
expect(holdingInsert).toBeDefined();
|
|
||||||
expect(holdingInsert!.params?.[0]).toBe(800);
|
|
||||||
|
|
||||||
// Whole thing commits; the move + holdings are one unit.
|
|
||||||
const execCalls = fake.calls.filter(
|
|
||||||
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
|
|
||||||
);
|
|
||||||
expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
|
|
||||||
expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back the move (date + holdings) when the target date collides (#200)", async () => {
|
|
||||||
queueSelects([{ id: 99 }]); // collision: another snapshot already at target
|
|
||||||
queueExecutes(
|
|
||||||
{ rowsAffected: 0 }, // BEGIN
|
|
||||||
{ rowsAffected: 0 } // ROLLBACK
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
saveSnapshotAtomic({
|
|
||||||
existingSnapshotId: 5,
|
|
||||||
snapshot_date: "2026-04-15",
|
|
||||||
moveToDate: "2026-05-20",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
value: 100,
|
|
||||||
holdings: [
|
|
||||||
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ code: "snapshot_date_exists" });
|
|
||||||
|
|
||||||
// No date UPDATE, no holding INSERT — the collision aborted before any write.
|
|
||||||
expect(
|
|
||||||
fake.calls.some(
|
|
||||||
(c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date")
|
|
||||||
)
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
fake.calls.some(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_snapshot_holdings")
|
|
||||||
)
|
|
||||||
).toBe(false);
|
|
||||||
const execCalls = fake.calls.filter(
|
|
||||||
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
|
|
||||||
);
|
|
||||||
expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("integration — golden-value invariant: detailed line feeds aggregators like a simple line (#217)", () => {
|
|
||||||
// The regression contract: whatever the holdings are, the aggregated line
|
|
||||||
// value the service writes equals the value a simple account would carry —
|
|
||||||
// so getSnapshotTotalsByDate (which only ever reads l.value) is unchanged.
|
|
||||||
// We prove BOTH halves drive identical aggregator output by feeding the
|
|
||||||
// aggregator the canned total in each case and asserting equality.
|
|
||||||
|
|
||||||
it("a detailed account worth ΣV stores the same line value as a simple account worth V", async () => {
|
|
||||||
// --- Detailed path: holdings 100 + 200 ⇒ line value must be 300. ---
|
|
||||||
queueSelects([]); // dup-check
|
|
||||||
queueSelects([
|
|
||||||
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
|
||||||
]);
|
|
||||||
queueSelects([
|
|
||||||
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
|
||||||
]);
|
|
||||||
queueExecutes(
|
|
||||||
{ rowsAffected: 0 }, // BEGIN
|
|
||||||
{ lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
|
|
||||||
{ rowsAffected: 0 }, // DELETE lines
|
|
||||||
{ lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
|
|
||||||
{ rowsAffected: 0 }, // DELETE holdings
|
|
||||||
{ rowsAffected: 1 }, // UPSERT AAPL
|
|
||||||
{ rowsAffected: 1 }, // INSERT holding AAPL
|
|
||||||
{ rowsAffected: 1 }, // UPSERT MSFT
|
|
||||||
{ rowsAffected: 1 }, // INSERT holding MSFT
|
|
||||||
{ rowsAffected: 1 }, // UPDATE updated_at
|
|
||||||
{ rowsAffected: 0 } // COMMIT
|
|
||||||
);
|
|
||||||
await saveSnapshotAtomic({
|
|
||||||
existingSnapshotId: null,
|
|
||||||
snapshot_date: "2026-05-30",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
value: 300,
|
|
||||||
holdings: [
|
|
||||||
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
|
|
||||||
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const detailedLineInsert = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
|
||||||
);
|
|
||||||
const detailedStoredValue = detailedLineInsert!.params![2];
|
|
||||||
expect(detailedStoredValue).toBe(300);
|
|
||||||
|
|
||||||
// --- Simple path: a simple account worth 300 stores the same value. ---
|
|
||||||
fake = makeFakeDbLocal();
|
|
||||||
vi.mocked(getDb).mockResolvedValue(
|
|
||||||
{ select: fake.select, execute: fake.execute } as never
|
|
||||||
);
|
|
||||||
fake.selectQueue.push([
|
|
||||||
{ id: 5, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
|
|
||||||
]); // getSnapshotById
|
|
||||||
queueExecutes(
|
|
||||||
{ rowsAffected: 0 }, // BEGIN
|
|
||||||
{ rowsAffected: 0 }, // DELETE lines
|
|
||||||
{ lastInsertId: 700, rowsAffected: 1 }, // INSERT simple line
|
|
||||||
{ rowsAffected: 1 }, // UPDATE updated_at
|
|
||||||
{ rowsAffected: 0 } // COMMIT
|
|
||||||
);
|
|
||||||
await upsertSnapshotLines(5, [{ account_id: 7, value: 300 }]);
|
|
||||||
const simpleLineInsert = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
|
||||||
);
|
|
||||||
const simpleStoredValue = simpleLineInsert!.params![2];
|
|
||||||
|
|
||||||
// The frozen golden number: BOTH paths persist value = 300.
|
|
||||||
expect(detailedStoredValue).toBe(simpleStoredValue);
|
|
||||||
expect(simpleStoredValue).toBe(300);
|
|
||||||
|
|
||||||
// And the aggregator (which reads only l.value) returns the same total in
|
|
||||||
// both worlds — proven by feeding it the canned per-date SUM each path
|
|
||||||
// produces. Detailed world:
|
|
||||||
fake = makeFakeDbLocal();
|
|
||||||
vi.mocked(getDb).mockResolvedValue(
|
|
||||||
{ select: fake.select, execute: fake.execute } as never
|
|
||||||
);
|
|
||||||
fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
|
|
||||||
const detailedTotals = await getSnapshotTotalsByDate();
|
|
||||||
// Simple world: identical canned SUM ⇒ identical aggregator output.
|
|
||||||
fake = makeFakeDbLocal();
|
|
||||||
vi.mocked(getDb).mockResolvedValue(
|
|
||||||
{ select: fake.select, execute: fake.execute } as never
|
|
||||||
);
|
|
||||||
fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
|
|
||||||
const simpleTotals = await getSnapshotTotalsByDate();
|
|
||||||
expect(detailedTotals).toEqual(simpleTotals);
|
|
||||||
expect(detailedTotals).toEqual([{ snapshot_date: "2026-05-30", total: 300 }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("integration — snapshot deletion cascades to holdings at the service boundary (#217)", () => {
|
|
||||||
it("deleteSnapshot issues the DELETE that the DB cascades to lines + holdings", async () => {
|
|
||||||
// The two-hop CASCADE is a DB-FK behaviour (proven in lib.rs
|
|
||||||
// regression_snapshot_delete_cascades_to_holdings). At the service layer we
|
|
||||||
// assert the single DELETE is emitted on the right row — the FK does the
|
|
||||||
// rest. Guard against a regression that would start manually deleting
|
|
||||||
// holdings (a sign the CASCADE was dropped) or skip the parent delete.
|
|
||||||
fake.selectQueue.push([
|
|
||||||
{ id: 50, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
|
|
||||||
]); // getSnapshotById
|
|
||||||
fake.executeQueue.push({ rowsAffected: 1 });
|
|
||||||
await deleteSnapshot(50);
|
|
||||||
const deletes = fake.calls.filter(
|
|
||||||
(c) => typeof c.sql === "string" && c.sql.startsWith("DELETE")
|
|
||||||
);
|
|
||||||
// Exactly one DELETE — on balance_snapshots. No manual holdings/line wipes
|
|
||||||
// (the FK CASCADE handles those; a manual delete would be the regression).
|
|
||||||
expect(deletes).toHaveLength(1);
|
|
||||||
expect(deletes[0].sql).toContain("DELETE FROM balance_snapshots WHERE id = $1");
|
|
||||||
expect(deletes[0].params).toEqual([50]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A standalone FakeDb factory for the multi-DB golden-value test, which swaps
|
|
||||||
// the active db handle mid-test. Mirrors makeFakeDb but is reusable inline.
|
|
||||||
function makeFakeDbLocal(): FakeDb {
|
|
||||||
const db: FakeDb = {
|
|
||||||
calls: [],
|
|
||||||
selectQueue: [],
|
|
||||||
executeQueue: [],
|
|
||||||
select: vi.fn(),
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
|
|
||||||
db.calls.push({ sql, params });
|
|
||||||
if (db.selectQueue.length === 0) {
|
|
||||||
throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
|
|
||||||
}
|
|
||||||
return db.selectQueue.shift();
|
|
||||||
});
|
|
||||||
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
|
|
||||||
db.calls.push({ sql, params });
|
|
||||||
if (db.executeQueue.length === 0) {
|
|
||||||
return { rowsAffected: 1, lastInsertId: db.calls.length };
|
|
||||||
}
|
|
||||||
return db.executeQueue.shift();
|
|
||||||
});
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,9 @@
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("../services/db", () => {
|
vi.mock("../services/db", () => ({
|
||||||
const getDb = vi.fn();
|
getDb: vi.fn(),
|
||||||
return {
|
}));
|
||||||
getDb,
|
|
||||||
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,9 @@
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("../services/db", () => {
|
vi.mock("../services/db", () => ({
|
||||||
const getDb = vi.fn();
|
getDb: vi.fn(),
|
||||||
return {
|
}));
|
||||||
getDb,
|
|
||||||
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { getDb } from "../services/db";
|
import { getDb } from "../services/db";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,15 @@ import { FormEvent, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
BalanceAccount,
|
BalanceAccount,
|
||||||
BalanceAccountKind,
|
|
||||||
BalanceAssetType,
|
BalanceAssetType,
|
||||||
BalanceCategory,
|
BalanceCategory,
|
||||||
BalanceCategoryKind,
|
BalanceCategoryKind,
|
||||||
BalanceVehicleType,
|
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
import { BALANCE_VEHICLE_TYPES } from "../../shared/types";
|
|
||||||
import type {
|
import type {
|
||||||
CreateBalanceAccountInput,
|
CreateBalanceAccountInput,
|
||||||
CreateBalanceCategoryInput,
|
CreateBalanceCategoryInput,
|
||||||
UpdateBalanceAccountInput,
|
UpdateBalanceAccountInput,
|
||||||
} from "../../services/balance.service";
|
} from "../../services/balance.service";
|
||||||
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Account variant types
|
// Account variant types
|
||||||
|
|
@ -36,23 +32,6 @@ export interface AccountFormValues {
|
||||||
name: string;
|
name: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
/** Fiscal envelope; "" means "no envelope" (→ null in the payload). */
|
|
||||||
vehicle_type: BalanceVehicleType | "";
|
|
||||||
/**
|
|
||||||
* Entry mode (Issue #213). 'simple' = one scalar value; 'detailed' = a basket
|
|
||||||
* of per-security holdings. Drives the symbol/price-fetch gating. For a NEW
|
|
||||||
* account the category kind only *suggests* the default (priced → detailed);
|
|
||||||
* the persisted mode is set on the account itself, and the detailed→simple
|
|
||||||
* downgrade is forbidden once holdings exist (service backstop).
|
|
||||||
*/
|
|
||||||
kind: BalanceAccountKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map a category's kind to the suggested default account entry mode (#213). */
|
|
||||||
function defaultKindForCategory(
|
|
||||||
category: BalanceCategory | undefined
|
|
||||||
): BalanceAccountKind {
|
|
||||||
return category?.kind === "priced" ? "detailed" : "simple";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountVariantProps {
|
interface AccountVariantProps {
|
||||||
|
|
@ -73,12 +52,7 @@ interface AccountVariantProps {
|
||||||
|
|
||||||
export interface CategoryFormValues {
|
export interface CategoryFormValues {
|
||||||
key: string;
|
key: string;
|
||||||
/**
|
i18n_key: string;
|
||||||
* User-facing label (Issue #203). Written to `custom_label`, never to
|
|
||||||
* `i18n_key` — consistent with the rename flow and the bug-I fix. The
|
|
||||||
* service derives `i18n_key` from the key for user-created categories.
|
|
||||||
*/
|
|
||||||
custom_label: string;
|
|
||||||
kind: BalanceCategoryKind;
|
kind: BalanceCategoryKind;
|
||||||
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
|
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
|
||||||
asset_type: BalanceAssetType | null;
|
asset_type: BalanceAssetType | null;
|
||||||
|
|
@ -103,9 +77,6 @@ function defaultAccountValues(
|
||||||
name: initial.name,
|
name: initial.name,
|
||||||
symbol: initial.symbol ?? "",
|
symbol: initial.symbol ?? "",
|
||||||
notes: initial.notes ?? "",
|
notes: initial.notes ?? "",
|
||||||
vehicle_type: initial.vehicle_type ?? "",
|
|
||||||
// Editing: the account's stored entry mode is authoritative.
|
|
||||||
kind: initial.kind,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// First active category as a sane default
|
// First active category as a sane default
|
||||||
|
|
@ -115,9 +86,6 @@ function defaultAccountValues(
|
||||||
name: "",
|
name: "",
|
||||||
symbol: "",
|
symbol: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
vehicle_type: "",
|
|
||||||
// New account: suggest the category-mapped default entry mode.
|
|
||||||
kind: defaultKindForCategory(first),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,31 +120,27 @@ function AccountVariant({
|
||||||
}, [initialAccount, categories]);
|
}, [initialAccount, categories]);
|
||||||
|
|
||||||
const isEditing = !!initialAccount;
|
const isEditing = !!initialAccount;
|
||||||
// Symbol / price-fetch gating now keys on the ACCOUNT's entry mode (#213),
|
const selectedCategory = categories.find(
|
||||||
// not the category kind. `category.kind` only seeds the default below.
|
(c) => c.id === values.balance_category_id
|
||||||
const isDetailed = values.kind === "detailed";
|
);
|
||||||
|
const isPriced = selectedCategory?.kind === "priced";
|
||||||
const trimmedName = values.name.trim();
|
const trimmedName = values.name.trim();
|
||||||
const trimmedSymbol = values.symbol.trim();
|
const trimmedSymbol = values.symbol.trim();
|
||||||
const nameInvalid = touched && trimmedName.length === 0;
|
const nameInvalid = touched && trimmedName.length === 0;
|
||||||
// Symbol is optional even for priced categories (Issue #199). It only
|
// Priced categories require a symbol — surfaced as a validation error.
|
||||||
// gates the price-fetch button — manual valuation (quantity × unit price)
|
const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0;
|
||||||
// never needs it. So no symbol-required validation here.
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
if (!trimmedName) return;
|
if (!trimmedName) return;
|
||||||
|
if (isPriced && !trimmedSymbol) return;
|
||||||
// "" in the select means "no envelope" → null on the wire.
|
|
||||||
const vehicleType: BalanceVehicleType | null =
|
|
||||||
values.vehicle_type === "" ? null : values.vehicle_type;
|
|
||||||
|
|
||||||
const payload: CreateBalanceAccountInput = {
|
const payload: CreateBalanceAccountInput = {
|
||||||
balance_category_id: values.balance_category_id,
|
balance_category_id: values.balance_category_id,
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
symbol: trimmedSymbol || null,
|
symbol: trimmedSymbol || null,
|
||||||
notes: values.notes.trim() || null,
|
notes: values.notes.trim() || null,
|
||||||
vehicle_type: vehicleType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
|
|
@ -185,26 +149,16 @@ function AccountVariant({
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
symbol: payload.symbol,
|
symbol: payload.symbol,
|
||||||
notes: payload.notes,
|
notes: payload.notes,
|
||||||
vehicle_type: vehicleType,
|
|
||||||
};
|
};
|
||||||
// Forward the entry mode only when it actually changed (avoids a no-op
|
|
||||||
// downgrade attempt; the detailed → simple direction is service-guarded
|
|
||||||
// and the selector is locked for detailed accounts anyway). A simple →
|
|
||||||
// detailed upgrade here flags the account for per-title entry; the pivot
|
|
||||||
// date wizard (#215) will own setting `detailed_since`.
|
|
||||||
if (initialAccount && values.kind !== initialAccount.kind) {
|
|
||||||
updatePayload.kind = values.kind;
|
|
||||||
}
|
|
||||||
await onSubmit(updatePayload);
|
await onSubmit(updatePayload);
|
||||||
} else {
|
} else {
|
||||||
// NEW accounts persist as the DB default ('simple') —
|
|
||||||
// `CreateBalanceAccountInput` carries no `kind`. Converting a fresh
|
|
||||||
// account to detailed happens via the dedicated flow (#215); the
|
|
||||||
// selector above only previews the suggested mode + gating.
|
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderCategoryLabel = (cat: BalanceCategory) =>
|
||||||
|
t(cat.i18n_key, { defaultValue: cat.key });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -214,17 +168,12 @@ function AccountVariant({
|
||||||
<select
|
<select
|
||||||
id="account-category"
|
id="account-category"
|
||||||
value={values.balance_category_id}
|
value={values.balance_category_id}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const nextId = Number(e.target.value);
|
setValues({
|
||||||
const nextCat = categories.find((c) => c.id === nextId);
|
...values,
|
||||||
setValues((prev) => ({
|
balance_category_id: Number(e.target.value),
|
||||||
...prev,
|
})
|
||||||
balance_category_id: nextId,
|
}
|
||||||
// For a NEW account, re-suggest the entry mode from the chosen
|
|
||||||
// category. When editing, the stored kind stays put.
|
|
||||||
kind: isEditing ? prev.kind : defaultKindForCategory(nextCat),
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
>
|
>
|
||||||
{categories.length === 0 ? (
|
{categories.length === 0 ? (
|
||||||
|
|
@ -232,7 +181,7 @@ function AccountVariant({
|
||||||
) : (
|
) : (
|
||||||
categories.map((cat) => (
|
categories.map((cat) => (
|
||||||
<option key={cat.id} value={cat.id}>
|
<option key={cat.id} value={cat.id}>
|
||||||
{renderCategoryLabelFromCategory(cat, t)}
|
{renderCategoryLabel(cat)}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -267,7 +216,7 @@ function AccountVariant({
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
|
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
|
||||||
{t("balance.account.form.symbol")}
|
{t("balance.account.form.symbol")}
|
||||||
{isDetailed && (
|
{isPriced && (
|
||||||
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
|
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
|
||||||
({t("balance.account.form.symbolPricedHint")})
|
({t("balance.account.form.symbolPricedHint")})
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -280,52 +229,22 @@ function AccountVariant({
|
||||||
onChange={(e) => setValues({ ...values, symbol: e.target.value })}
|
onChange={(e) => setValues({ ...values, symbol: e.target.value })}
|
||||||
onBlur={() => setTouched(true)}
|
onBlur={() => setTouched(true)}
|
||||||
placeholder={
|
placeholder={
|
||||||
isDetailed
|
isPriced
|
||||||
? t("balance.account.form.symbolPlaceholderPriced")
|
? t("balance.account.form.symbolPlaceholderPriced")
|
||||||
: t("balance.account.form.symbolPlaceholderSimple")
|
: t("balance.account.form.symbolPlaceholderSimple")
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
|
||||||
|
symbolMissingForPriced
|
||||||
|
? "border-[var(--negative)]"
|
||||||
|
: "border-[var(--border)]"
|
||||||
|
}`}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
{symbolMissingForPriced && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--negative)]">
|
||||||
<div>
|
{t("balance.account.form.symbolRequiredForPriced")}
|
||||||
<label
|
</p>
|
||||||
className="block text-sm font-medium mb-1"
|
)}
|
||||||
htmlFor="account-kind"
|
|
||||||
>
|
|
||||||
{t("balance.account.form.kind.label")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="account-kind"
|
|
||||||
value={values.kind}
|
|
||||||
// Downgrading detailed → simple is forbidden once holdings exist
|
|
||||||
// (service backstop). The form can't see holdings, so editing a
|
|
||||||
// detailed account locks the selector; conversion lives in its own
|
|
||||||
// flow (#215). A new account can pick either mode.
|
|
||||||
disabled={isEditing && initialAccount?.kind === "detailed"}
|
|
||||||
onChange={(e) =>
|
|
||||||
setValues({
|
|
||||||
...values,
|
|
||||||
kind: e.target.value as BalanceAccountKind,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option value="simple">{t("balance.account.form.kind.simple")}</option>
|
|
||||||
<option value="detailed">
|
|
||||||
{t("balance.account.form.kind.detailed")}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.account.form.kind.hint")}
|
|
||||||
{!isEditing && values.kind === "detailed" && (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
{t("balance.account.form.kind.detailedCreateHint")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -341,36 +260,6 @@ function AccountVariant({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-medium mb-1"
|
|
||||||
htmlFor="account-vehicle-type"
|
|
||||||
>
|
|
||||||
{t("balance.account.form.vehicleType.label")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="account-vehicle-type"
|
|
||||||
value={values.vehicle_type}
|
|
||||||
onChange={(e) =>
|
|
||||||
setValues({
|
|
||||||
...values,
|
|
||||||
vehicle_type: e.target.value as BalanceVehicleType | "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
||||||
>
|
|
||||||
<option value="">{t("balance.account.form.vehicleType.none")}</option>
|
|
||||||
{BALANCE_VEHICLE_TYPES.map((vt) => (
|
|
||||||
<option key={vt} value={vt}>
|
|
||||||
{t(`balance.account.form.vehicleType.${vt}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.account.form.vehicleType.hint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
{t("balance.account.form.currencyMvpNotice")}
|
{t("balance.account.form.currencyMvpNotice")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -386,7 +275,12 @@ function AccountVariant({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSaving || !trimmedName || categories.length === 0}
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!trimmedName ||
|
||||||
|
categories.length === 0 ||
|
||||||
|
(isPriced && !trimmedSymbol)
|
||||||
|
}
|
||||||
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isEditing
|
{isEditing
|
||||||
|
|
@ -410,14 +304,14 @@ function CategoryVariant({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [values, setValues] = useState<CategoryFormValues>({
|
const [values, setValues] = useState<CategoryFormValues>({
|
||||||
key: "",
|
key: "",
|
||||||
custom_label: "",
|
i18n_key: "",
|
||||||
kind: "simple",
|
kind: "simple",
|
||||||
asset_type: null,
|
asset_type: null,
|
||||||
});
|
});
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
const trimmedKey = values.key.trim();
|
const trimmedKey = values.key.trim();
|
||||||
const trimmedLabel = values.custom_label.trim();
|
const trimmedLabel = values.i18n_key.trim();
|
||||||
const keyInvalid = touched && trimmedKey.length === 0;
|
const keyInvalid = touched && trimmedKey.length === 0;
|
||||||
const assetTypeMissing =
|
const assetTypeMissing =
|
||||||
touched && values.kind === "priced" && !values.asset_type;
|
touched && values.kind === "priced" && !values.asset_type;
|
||||||
|
|
@ -441,16 +335,14 @@ function CategoryVariant({
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
if (!trimmedKey) return;
|
if (!trimmedKey) return;
|
||||||
if (values.kind === "priced" && !values.asset_type) return;
|
if (values.kind === "priced" && !values.asset_type) return;
|
||||||
// User-created categories have no bundled translation: anchor `i18n_key`
|
// Fall back to the key if no human label was supplied.
|
||||||
// on the key (stable lookup, no free text) and carry the human label in
|
const i18nKey = trimmedLabel || trimmedKey;
|
||||||
// `custom_label` — same mechanism as renaming (Issue #203 / bug I).
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
key: trimmedKey,
|
key: trimmedKey,
|
||||||
i18n_key: `balance.category.${trimmedKey}`,
|
i18n_key: i18nKey,
|
||||||
kind: values.kind,
|
kind: values.kind,
|
||||||
sort_order: 100, // user-created categories sort after seeded ones
|
sort_order: 100, // user-created categories sort after seeded ones
|
||||||
asset_type: values.kind === "priced" ? values.asset_type : null,
|
asset_type: values.kind === "priced" ? values.asset_type : null,
|
||||||
custom_label: trimmedLabel || null,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -490,24 +382,21 @@ function CategoryVariant({
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-sm font-medium mb-1"
|
className="block text-sm font-medium mb-1"
|
||||||
htmlFor="category-custom-label"
|
htmlFor="category-label"
|
||||||
>
|
>
|
||||||
{t("balance.category.form.customLabel")}
|
{t("balance.category.form.label")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="category-custom-label"
|
id="category-label"
|
||||||
type="text"
|
type="text"
|
||||||
value={values.custom_label}
|
value={values.i18n_key}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setValues({ ...values, custom_label: e.target.value })
|
setValues({ ...values, i18n_key: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={t("balance.category.form.customLabelPlaceholder")}
|
placeholder={t("balance.category.form.labelPlaceholder")}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.category.form.customLabelHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,38 +18,15 @@
|
||||||
// that opens `LinkTransfersModal` (the modal handles its own state; this
|
// that opens `LinkTransfersModal` (the modal handles its own state; this
|
||||||
// component just bubbles up the request via `onLinkTransfers`).
|
// component just bubbles up the request via `onLinkTransfers`).
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
|
||||||
Archive,
|
|
||||||
MoreVertical,
|
|
||||||
Link as LinkIcon,
|
|
||||||
AlertTriangle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
ListTree,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type {
|
import type {
|
||||||
AccountLatestSnapshot,
|
AccountLatestSnapshot,
|
||||||
AccountPeriodAnchor,
|
AccountPeriodAnchor,
|
||||||
AccountUnrealizedGain,
|
|
||||||
LatentGainRollup,
|
|
||||||
} from "../../services/balance.service";
|
} from "../../services/balance.service";
|
||||||
import { computeAccountReturn } from "../../services/balance.service";
|
import { computeAccountReturn } from "../../services/balance.service";
|
||||||
import {
|
|
||||||
getPreference,
|
|
||||||
setPreference,
|
|
||||||
} from "../../services/userPreferenceService";
|
|
||||||
import type { AccountReturn } from "../../shared/types";
|
import type { AccountReturn } from "../../shared/types";
|
||||||
import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preference key persisting whether the 4 Modified-Dietz return columns are
|
|
||||||
* expanded. Absent/anything-but-"1" → collapsed (the spec default). Stored via
|
|
||||||
* `userPreferenceService` so the choice survives across sessions (Issue #204).
|
|
||||||
*/
|
|
||||||
const SHOW_RETURNS_PREF_KEY = "balance_show_returns";
|
|
||||||
|
|
||||||
const cadFormatter = (locale: string) =>
|
const cadFormatter = (locale: string) =>
|
||||||
new Intl.NumberFormat(locale, {
|
new Intl.NumberFormat(locale, {
|
||||||
|
|
@ -87,29 +64,12 @@ interface BalanceAccountsTableProps {
|
||||||
periodAnchor: AccountPeriodAnchor[];
|
periodAnchor: AccountPeriodAnchor[];
|
||||||
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
||||||
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
|
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
|
||||||
/**
|
|
||||||
* Open the "détailler en titres" wizard for a *simple* account (Issue #215).
|
|
||||||
* The action is only offered on `kind === 'simple'` rows; once detailed, the
|
|
||||||
* flip is one-way (service backstop #212) so no inverse action is exposed.
|
|
||||||
*/
|
|
||||||
onDetailAccount?: (account: AccountLatestSnapshot) => void;
|
|
||||||
/**
|
/**
|
||||||
* Earliest snapshot date across the whole profile, used to anchor the
|
* Earliest snapshot date across the whole profile, used to anchor the
|
||||||
* "depuis création" horizon. Falls back to "1A" range if not provided
|
* "depuis création" horizon. Falls back to "1A" range if not provided
|
||||||
* (avoids triggering computation against the unix epoch).
|
* (avoids triggering computation against the unix epoch).
|
||||||
*/
|
*/
|
||||||
sinceCreationDate?: string | null;
|
sinceCreationDate?: string | null;
|
||||||
/**
|
|
||||||
* Per-account unrealized (latent) gain keyed by `account_id`, prefetched by
|
|
||||||
* `useBalanceOverview` from each detailed account's latest holdings (Issue
|
|
||||||
* #216). Drives the latent-gain column + the per-security drill-down. Only
|
|
||||||
* detailed accounts with holdings appear; absent ⇒ no figure / no drill-down.
|
|
||||||
*/
|
|
||||||
latentGainByAccount?: Record<number, AccountUnrealizedGain>;
|
|
||||||
/** Latent gain rolled up by asset class / envelope, for the summary block (#216). */
|
|
||||||
latentGainRollup?: LatentGainRollup;
|
|
||||||
/** vehicle_type code (incl. 'none') → translated label, for the envelope rollup (#216). */
|
|
||||||
vehicleLabels?: Record<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,11 +83,7 @@ export default function BalanceAccountsTable({
|
||||||
periodAnchor,
|
periodAnchor,
|
||||||
onArchiveAccount,
|
onArchiveAccount,
|
||||||
onLinkTransfers,
|
onLinkTransfers,
|
||||||
onDetailAccount,
|
|
||||||
sinceCreationDate,
|
sinceCreationDate,
|
||||||
latentGainByAccount = {},
|
|
||||||
latentGainRollup,
|
|
||||||
vehicleLabels = {},
|
|
||||||
}: BalanceAccountsTableProps) {
|
}: BalanceAccountsTableProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
||||||
|
|
@ -141,54 +97,8 @@ export default function BalanceAccountsTable({
|
||||||
|
|
||||||
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
||||||
|
|
||||||
// Per-security drill-down (Issue #216): which detailed account rows are
|
|
||||||
// expanded. A Set keyed by account_id; toggled by the row's chevron. The
|
|
||||||
// holdings themselves are already prefetched in `latentGainByAccount`, so
|
|
||||||
// expanding is a pure render — no extra DB round-trip.
|
|
||||||
const [expandedFor, setExpandedFor] = useState<Set<number>>(new Set());
|
|
||||||
const toggleExpanded = (accountId: number) => {
|
|
||||||
setExpandedFor((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(accountId)) next.delete(accountId);
|
|
||||||
else next.add(accountId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Progressive disclosure of the 4 return columns (Issue #204). Collapsed by
|
|
||||||
// default; the persisted choice is read once on mount. We start `false` so
|
|
||||||
// the columns never flash open before the preference resolves.
|
|
||||||
const [showReturns, setShowReturns] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const stored = await getPreference(SHOW_RETURNS_PREF_KEY);
|
|
||||||
if (!cancelled && stored === "1") setShowReturns(true);
|
|
||||||
} catch {
|
|
||||||
// Pref read failure: keep the collapsed default.
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleReturns = () => {
|
|
||||||
setShowReturns((prev) => {
|
|
||||||
const next = !prev;
|
|
||||||
// Best-effort persist; a write failure just means the next session
|
|
||||||
// falls back to the collapsed default.
|
|
||||||
void setPreference(SHOW_RETURNS_PREF_KEY, next ? "1" : "0").catch(
|
|
||||||
() => {}
|
|
||||||
);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Returns cache. Cleared whenever the account list changes (new accounts,
|
// Returns cache. Cleared whenever the account list changes (new accounts,
|
||||||
// archive, etc.). Loaded lazily after mount — only while the columns are
|
// archive, etc.). Loaded lazily after mount.
|
||||||
// shown, so a collapsed table never runs the Modified-Dietz computation.
|
|
||||||
const [returns, setReturns] = useState<ReturnsByAccount>({});
|
const [returns, setReturns] = useState<ReturnsByAccount>({});
|
||||||
const [returnsLoading, setReturnsLoading] = useState(false);
|
const [returnsLoading, setReturnsLoading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -209,7 +119,7 @@ export default function BalanceAccountsTable({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadReturns() {
|
async function loadReturns() {
|
||||||
if (!showReturns || accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
setReturns({});
|
setReturns({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +154,7 @@ export default function BalanceAccountsTable({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [accounts, horizons, showReturns]);
|
}, [accounts, horizons]);
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -319,82 +229,10 @@ export default function BalanceAccountsTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Latent (unrealized) gain cell — value + % (Issue #216). `gain`/`gainPct`
|
|
||||||
* null ⇒ the shared service guard already decided "N/A" (book_cost NULL or 0);
|
|
||||||
* render the i18n N/A string, never a divide-by-zero. `partial` flags an
|
|
||||||
* aggregate where some holdings had an unknown book_cost (% understated).
|
|
||||||
*/
|
|
||||||
function renderLatentGainCell(
|
|
||||||
gain: number | null,
|
|
||||||
gainPct: number | null,
|
|
||||||
partial = false
|
|
||||||
) {
|
|
||||||
if (gain === null) {
|
|
||||||
return (
|
|
||||||
<span className="text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.latentGain.na")}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const positive = gain >= 0;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center justify-end gap-1 ${
|
|
||||||
positive ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{positive ? "+" : ""}
|
|
||||||
{fmt.format(gain)}
|
|
||||||
</span>
|
|
||||||
{gainPct !== null && (
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
({positive ? "+" : ""}
|
|
||||||
{(gainPct * 100).toFixed(2)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{partial && (
|
|
||||||
<AlertTriangle
|
|
||||||
size={12}
|
|
||||||
className="text-amber-500"
|
|
||||||
aria-label={t("balance.latentGain.partial")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The latent-gain column only earns its place when at least one detailed
|
|
||||||
// account has a computed gain — otherwise we keep the table narrow.
|
|
||||||
const hasLatentGain = accounts.some(
|
|
||||||
(a) => latentGainByAccount[a.account_id] !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
||||||
<div className="flex justify-end">
|
<table className="w-full text-sm">
|
||||||
<button
|
<thead className="bg-[var(--muted)]/30">
|
||||||
type="button"
|
|
||||||
onClick={toggleReturns}
|
|
||||||
aria-expanded={showReturns}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
>
|
|
||||||
{showReturns ? (
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
) : (
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
)}
|
|
||||||
{t(
|
|
||||||
showReturns
|
|
||||||
? "balance.accountsTable.toggleReturns.hide"
|
|
||||||
: "balance.accountsTable.toggleReturns.show"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-[var(--muted)]/30">
|
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 font-medium">
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
{t("balance.account.fields.name")}
|
{t("balance.account.fields.name")}
|
||||||
|
|
@ -408,30 +246,18 @@ export default function BalanceAccountsTable({
|
||||||
<th className="text-right px-4 py-3 font-medium">
|
<th className="text-right px-4 py-3 font-medium">
|
||||||
{t("balance.overview.periodDelta")}
|
{t("balance.overview.periodDelta")}
|
||||||
</th>
|
</th>
|
||||||
{hasLatentGain && (
|
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
|
||||||
<th
|
{t("balance.accountsTable.return3m")}
|
||||||
className="text-right px-4 py-3 font-medium"
|
</th>
|
||||||
title={t("balance.latentGain.tooltip")}
|
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
|
||||||
>
|
{t("balance.accountsTable.return1y")}
|
||||||
{t("balance.latentGain.column")}
|
</th>
|
||||||
</th>
|
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
|
||||||
)}
|
{t("balance.accountsTable.sinceCreation")}
|
||||||
{showReturns && (
|
</th>
|
||||||
<>
|
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
|
{t("balance.accountsTable.unadjusted")}
|
||||||
{t("balance.accountsTable.return3m")}
|
</th>
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
|
|
||||||
{t("balance.accountsTable.return1y")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
|
|
||||||
{t("balance.accountsTable.sinceCreation")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
|
|
||||||
{t("balance.accountsTable.unadjusted")}
|
|
||||||
</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<th className="text-right px-4 py-3 font-medium w-12">
|
<th className="text-right px-4 py-3 font-medium w-12">
|
||||||
{t("balance.account.fields.actions")}
|
{t("balance.account.fields.actions")}
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -447,44 +273,13 @@ export default function BalanceAccountsTable({
|
||||||
100
|
100
|
||||||
: null;
|
: null;
|
||||||
const accReturns = returns[acc.account_id] ?? {};
|
const accReturns = returns[acc.account_id] ?? {};
|
||||||
const lg = latentGainByAccount[acc.account_id];
|
|
||||||
// A detailed account is drillable once its latest holdings are
|
|
||||||
// loaded. Simple accounts (no holdings) never expand.
|
|
||||||
const drillable = !!lg && lg.holdings.length > 0;
|
|
||||||
const isExpanded = drillable && expandedFor.has(acc.account_id);
|
|
||||||
// Number of columns a drill-down sub-row must span.
|
|
||||||
const colSpan =
|
|
||||||
5 + (hasLatentGain ? 1 : 0) + (showReturns ? 4 : 0);
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={acc.account_id}>
|
|
||||||
<tr
|
<tr
|
||||||
|
key={acc.account_id}
|
||||||
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium">
|
<td className="px-4 py-3 font-medium">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
{acc.account_name}
|
||||||
{drillable ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleExpanded(acc.account_id)}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-label={t(
|
|
||||||
isExpanded
|
|
||||||
? "balance.latentGain.drilldown.collapse"
|
|
||||||
: "balance.latentGain.drilldown.expand"
|
|
||||||
)}
|
|
||||||
className="p-0.5 -ml-1 rounded hover:bg-[var(--muted)]/40 text-[var(--muted-foreground)]"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
) : (
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="inline-block w-[14px]" aria-hidden />
|
|
||||||
)}
|
|
||||||
{acc.account_name}
|
|
||||||
</span>
|
|
||||||
{acc.symbol ? (
|
{acc.symbol ? (
|
||||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||||
({acc.symbol})
|
({acc.symbol})
|
||||||
|
|
@ -492,7 +287,7 @@ export default function BalanceAccountsTable({
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--muted-foreground)]">
|
<td className="px-4 py-3 text-[var(--muted-foreground)]">
|
||||||
{renderCategoryLabelFromAccount(acc, t)}
|
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
|
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
|
||||||
|
|
@ -513,41 +308,26 @@ export default function BalanceAccountsTable({
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
{hasLatentGain && (
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
{returnsLoading && !accReturns["3M"]
|
||||||
{lg
|
? "…"
|
||||||
? renderLatentGainCell(
|
: renderReturnCell(accReturns["3M"])}
|
||||||
lg.total_gain,
|
</td>
|
||||||
lg.total_gain_pct,
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
lg.has_unknown_book_cost
|
{returnsLoading && !accReturns["1A"]
|
||||||
)
|
? "…"
|
||||||
: "—"}
|
: renderReturnCell(accReturns["1A"])}
|
||||||
</td>
|
</td>
|
||||||
)}
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
{showReturns && (
|
{returnsLoading && !accReturns["since"]
|
||||||
<>
|
? "…"
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
: renderReturnCell(accReturns["since"])}
|
||||||
{returnsLoading && !accReturns["3M"]
|
</td>
|
||||||
? "…"
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
: renderReturnCell(accReturns["3M"])}
|
{returnsLoading && !accReturns["1A"]
|
||||||
</td>
|
? "…"
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
: renderUnadjustedCell(accReturns["1A"])}
|
||||||
{returnsLoading && !accReturns["1A"]
|
</td>
|
||||||
? "…"
|
|
||||||
: renderReturnCell(accReturns["1A"])}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{returnsLoading && !accReturns["since"]
|
|
||||||
? "…"
|
|
||||||
: renderReturnCell(accReturns["since"])}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{returnsLoading && !accReturns["1A"]
|
|
||||||
? "…"
|
|
||||||
: renderUnadjustedCell(accReturns["1A"])}
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<td className="px-4 py-3 text-right relative">
|
<td className="px-4 py-3 text-right relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -563,19 +343,14 @@ export default function BalanceAccountsTable({
|
||||||
</button>
|
</button>
|
||||||
{openMenuFor === acc.account_id && (
|
{openMenuFor === acc.account_id && (
|
||||||
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
|
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
|
||||||
{onDetailAccount && acc.kind === "simple" && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
disabled
|
||||||
onClick={() => {
|
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
setOpenMenuFor(null);
|
title={t("balance.overview.detailComingSoon")}
|
||||||
onDetailAccount(acc);
|
>
|
||||||
}}
|
{t("balance.overview.detailAction")}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
</button>
|
||||||
>
|
|
||||||
<ListTree size={14} />
|
|
||||||
{t("balance.detailWizard.action")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onLinkTransfers && (
|
{onLinkTransfers && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -604,147 +379,10 @@ export default function BalanceAccountsTable({
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
|
||||||
<tr className="bg-[var(--muted)]/5">
|
|
||||||
<td colSpan={colSpan} className="px-0 py-0">
|
|
||||||
<div className="px-4 py-2">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-[var(--muted-foreground)]">
|
|
||||||
<th className="text-left font-medium py-1 pl-6">
|
|
||||||
{t("balance.latentGain.drilldown.security")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right font-medium py-1">
|
|
||||||
{t("balance.latentGain.drilldown.value")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right font-medium py-1 pr-2">
|
|
||||||
{t("balance.latentGain.drilldown.gain")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{lg!.holdings.map((h) => (
|
|
||||||
<tr key={h.security_id}>
|
|
||||||
<td className="text-left py-1 pl-6 font-medium">
|
|
||||||
{h.symbol}
|
|
||||||
</td>
|
|
||||||
<td className="text-right py-1 tabular-nums">
|
|
||||||
{fmt.format(h.value)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right py-1 pr-2 tabular-nums">
|
|
||||||
{renderLatentGainCell(h.gain, h.gain_pct)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{latentGainRollup &&
|
|
||||||
(latentGainRollup.byClass.length > 0 ||
|
|
||||||
latentGainRollup.byVehicle.length > 0) && (
|
|
||||||
<LatentGainSummary
|
|
||||||
rollup={latentGainRollup}
|
|
||||||
accounts={accounts}
|
|
||||||
vehicleLabels={vehicleLabels}
|
|
||||||
fmt={fmt}
|
|
||||||
renderGain={renderLatentGainCell}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// LatentGainSummary — aggregated latent gain by asset class / envelope (#216).
|
|
||||||
// Reuses the accounts surface (no new chart): a compact two-column block of
|
|
||||||
// rollup figures. Asset-class labels resolve from the accounts payload (same
|
|
||||||
// renderCategoryLabel path the table uses); envelope labels come from the
|
|
||||||
// shared `vehicleLabels` map BalancePage already builds.
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface LatentGainSummaryProps {
|
|
||||||
rollup: LatentGainRollup;
|
|
||||||
accounts: AccountLatestSnapshot[];
|
|
||||||
vehicleLabels: Record<string, string>;
|
|
||||||
fmt: Intl.NumberFormat;
|
|
||||||
renderGain: (
|
|
||||||
gain: number | null,
|
|
||||||
gainPct: number | null,
|
|
||||||
partial?: boolean
|
|
||||||
) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LatentGainSummary({
|
|
||||||
rollup,
|
|
||||||
accounts,
|
|
||||||
vehicleLabels,
|
|
||||||
renderGain,
|
|
||||||
}: LatentGainSummaryProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// category_key → translated asset-class label, from the accounts payload.
|
|
||||||
const classLabels = useMemo(() => {
|
|
||||||
const m: Record<string, string> = {};
|
|
||||||
for (const a of accounts) {
|
|
||||||
if (!m[a.category_key]) {
|
|
||||||
m[a.category_key] = renderCategoryLabelFromAccount(a, t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [accounts, t]);
|
|
||||||
|
|
||||||
const section = (
|
|
||||||
title: string,
|
|
||||||
buckets: LatentGainRollup["byClass"],
|
|
||||||
labelFor: (key: string) => string
|
|
||||||
) => (
|
|
||||||
<div className="flex-1 min-w-[220px]">
|
|
||||||
<p className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide mb-1">
|
|
||||||
{title}
|
|
||||||
</p>
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<tbody>
|
|
||||||
{buckets.map((b) => (
|
|
||||||
<tr key={b.group_key} className="border-t border-[var(--border)]/60">
|
|
||||||
<td className="py-1.5 text-[var(--muted-foreground)]">
|
|
||||||
{labelFor(b.group_key)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 text-right tabular-nums">
|
|
||||||
{renderGain(b.total_gain, b.total_gain_pct, b.has_unknown_book_cost)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
|
||||||
<h3 className="text-sm font-semibold mb-3">
|
|
||||||
{t("balance.latentGain.summary.title")}
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-6">
|
|
||||||
{section(
|
|
||||||
t("balance.latentGain.summary.byClass"),
|
|
||||||
rollup.byClass,
|
|
||||||
(key) => classLabels[key] ?? key
|
|
||||||
)}
|
|
||||||
{section(
|
|
||||||
t("balance.latentGain.summary.byVehicle"),
|
|
||||||
rollup.byVehicle,
|
|
||||||
(key) => vehicleLabels[key] ?? key
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,8 @@ import {
|
||||||
import type {
|
import type {
|
||||||
SnapshotTotalPoint,
|
SnapshotTotalPoint,
|
||||||
SnapshotCategoryBreakdownPoint,
|
SnapshotCategoryBreakdownPoint,
|
||||||
SnapshotVehicleBreakdownPoint,
|
|
||||||
} from "../../services/balance.service";
|
} from "../../services/balance.service";
|
||||||
import type {
|
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
|
||||||
BalanceChartMode,
|
|
||||||
BalanceGroupAxis,
|
|
||||||
} from "../../hooks/useBalanceOverview";
|
|
||||||
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
|
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
|
||||||
|
|
||||||
// Stable palette for the stacked-by-category areas. Indexed deterministically
|
// Stable palette for the stacked-by-category areas. Indexed deterministically
|
||||||
|
|
@ -53,20 +49,10 @@ const CATEGORY_PALETTE = [
|
||||||
|
|
||||||
export interface BalanceEvolutionChartProps {
|
export interface BalanceEvolutionChartProps {
|
||||||
mode: BalanceChartMode;
|
mode: BalanceChartMode;
|
||||||
/**
|
|
||||||
* Stacked-mode grouping axis (Issue #204). `'class'` stacks by asset class
|
|
||||||
* (the `byCategory` series), `'vehicle'` stacks by fiscal envelope (the
|
|
||||||
* `byVehicle` series). Ignored in line mode. Defaults to `'class'`.
|
|
||||||
*/
|
|
||||||
groupAxis?: BalanceGroupAxis;
|
|
||||||
totals: SnapshotTotalPoint[];
|
totals: SnapshotTotalPoint[];
|
||||||
byCategory: SnapshotCategoryBreakdownPoint[];
|
byCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
/** Per-vehicle breakdown for the `groupAxis === 'vehicle'` stacked variant. */
|
|
||||||
byVehicle?: SnapshotVehicleBreakdownPoint[];
|
|
||||||
/** Map category_key → translated label so the legend reads naturally. */
|
/** Map category_key → translated label so the legend reads naturally. */
|
||||||
categoryLabels?: Record<string, string>;
|
categoryLabels?: Record<string, string>;
|
||||||
/** Map vehicle_key (incl. 'none') → translated label for the vehicle axis. */
|
|
||||||
vehicleLabels?: Record<string, string>;
|
|
||||||
/**
|
/**
|
||||||
* Issue #142 — every linked transfer in the visible range. Rendered as
|
* Issue #142 — every linked transfer in the visible range. Rendered as
|
||||||
* vertical `<ReferenceLine>` markers on the X axis: green for `in`
|
* vertical `<ReferenceLine>` markers on the X axis: green for `in`
|
||||||
|
|
@ -78,37 +64,13 @@ export interface BalanceEvolutionChartProps {
|
||||||
|
|
||||||
export default function BalanceEvolutionChart({
|
export default function BalanceEvolutionChart({
|
||||||
mode,
|
mode,
|
||||||
groupAxis = "class",
|
|
||||||
totals,
|
totals,
|
||||||
byCategory,
|
byCategory,
|
||||||
byVehicle = [],
|
|
||||||
categoryLabels = {},
|
categoryLabels = {},
|
||||||
vehicleLabels = {},
|
|
||||||
transferMarkers = [],
|
transferMarkers = [],
|
||||||
}: BalanceEvolutionChartProps) {
|
}: BalanceEvolutionChartProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
// The stacked chart is driven by whichever axis is active. Both breakdowns
|
|
||||||
// share the `{ snapshot_date, <map> }` shape, so we normalize to a common
|
|
||||||
// `{ snapshot_date, series }` form here and feed a single rendering path.
|
|
||||||
const stackedSource = useMemo(
|
|
||||||
() =>
|
|
||||||
groupAxis === "vehicle"
|
|
||||||
? byVehicle.map((p) => ({
|
|
||||||
snapshot_date: p.snapshot_date,
|
|
||||||
series: p.byVehicle,
|
|
||||||
}))
|
|
||||||
: byCategory.map((p) => ({
|
|
||||||
snapshot_date: p.snapshot_date,
|
|
||||||
series: p.byCategory,
|
|
||||||
})),
|
|
||||||
[groupAxis, byCategory, byVehicle]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Label map for the active axis (legend + tooltip). Falls back to the raw
|
|
||||||
// key when no translation is provided.
|
|
||||||
const activeLabels = groupAxis === "vehicle" ? vehicleLabels : categoryLabels;
|
|
||||||
|
|
||||||
const cadFormatter = useMemo(
|
const cadFormatter = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
|
@ -139,26 +101,25 @@ export default function BalanceEvolutionChart({
|
||||||
|
|
||||||
// --- Stacked-area dataset ---
|
// --- Stacked-area dataset ---
|
||||||
// We transpose the per-snapshot bucket into one row per snapshot_date with
|
// We transpose the per-snapshot bucket into one row per snapshot_date with
|
||||||
// one column per series key (asset class OR fiscal envelope, per groupAxis).
|
// one column per category_key. Categories absent at a snapshot date are
|
||||||
// Keys absent at a snapshot date are emitted as 0 so Recharts renders a
|
// emitted as 0 so Recharts renders a continuous stack.
|
||||||
// continuous stack.
|
|
||||||
const { stackedData, categoryKeys } = useMemo(() => {
|
const { stackedData, categoryKeys } = useMemo(() => {
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
for (const point of stackedSource) {
|
for (const point of byCategory) {
|
||||||
for (const k of Object.keys(point.series)) keys.add(k);
|
for (const k of Object.keys(point.byCategory)) keys.add(k);
|
||||||
}
|
}
|
||||||
const orderedKeys = Array.from(keys).sort();
|
const orderedKeys = Array.from(keys).sort();
|
||||||
const data = stackedSource.map((point) => {
|
const data = byCategory.map((point) => {
|
||||||
const row: Record<string, string | number> = {
|
const row: Record<string, string | number> = {
|
||||||
snapshot_date: point.snapshot_date,
|
snapshot_date: point.snapshot_date,
|
||||||
};
|
};
|
||||||
for (const k of orderedKeys) {
|
for (const k of orderedKeys) {
|
||||||
row[k] = point.series[k] ?? 0;
|
row[k] = point.byCategory[k] ?? 0;
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
return { stackedData: data, categoryKeys: orderedKeys };
|
return { stackedData: data, categoryKeys: orderedKeys };
|
||||||
}, [stackedSource]);
|
}, [byCategory]);
|
||||||
|
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
|
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
|
||||||
|
|
@ -286,13 +247,13 @@ export default function BalanceEvolutionChart({
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number | undefined, name) => [
|
formatter={(value: number | undefined, name) => [
|
||||||
cadFormatter.format(value ?? 0),
|
cadFormatter.format(value ?? 0),
|
||||||
activeLabels[String(name)] ?? String(name),
|
categoryLabels[String(name)] ?? String(name),
|
||||||
]}
|
]}
|
||||||
labelFormatter={(label) => formatDate(String(label))}
|
labelFormatter={(label) => formatDate(String(label))}
|
||||||
contentStyle={tooltipContentStyle}
|
contentStyle={tooltipContentStyle}
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
formatter={(value) => activeLabels[String(value)] ?? String(value)}
|
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
|
||||||
/>
|
/>
|
||||||
{categoryKeys.map((key, idx) => (
|
{categoryKeys.map((key, idx) => (
|
||||||
<Area
|
<Area
|
||||||
|
|
|
||||||
41
src/components/balance/BalanceOnboardingCard.test.tsx
Normal file
41
src/components/balance/BalanceOnboardingCard.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
206
src/components/balance/BalanceOnboardingCard.tsx
Normal file
206
src/components/balance/BalanceOnboardingCard.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,10 +12,7 @@ import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type {
|
import type { SnapshotTotalPoint } from "../../services/balance.service";
|
||||||
LatentGainRollup,
|
|
||||||
SnapshotTotalPoint,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
|
|
||||||
const STALENESS_DAYS = 60;
|
const STALENESS_DAYS = 60;
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
|
|
@ -28,18 +25,9 @@ const cadFormatter = (value: number) =>
|
||||||
interface BalanceOverviewCardProps {
|
interface BalanceOverviewCardProps {
|
||||||
/** The full evolution series for the active period (latest at the end). */
|
/** The full evolution series for the active period (latest at the end). */
|
||||||
totals: SnapshotTotalPoint[];
|
totals: SnapshotTotalPoint[];
|
||||||
/**
|
|
||||||
* Aggregated latent gain across all detailed accounts (Issue #216). Shown as
|
|
||||||
* a total figure alongside the net worth. Absent / no detailed account ⇒ the
|
|
||||||
* latent-gain line is hidden (no zero-noise for users without securities).
|
|
||||||
*/
|
|
||||||
latentGainRollup?: LatentGainRollup;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BalanceOverviewCard({
|
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
|
||||||
totals,
|
|
||||||
latentGainRollup,
|
|
||||||
}: BalanceOverviewCardProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
|
|
@ -70,19 +58,6 @@ export default function BalanceOverviewCard({
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Total latent gain across detailed accounts. Only render when at least one
|
|
||||||
// detailed account contributed a bucket — otherwise the line is hidden.
|
|
||||||
const latent = useMemo(() => {
|
|
||||||
if (
|
|
||||||
!latentGainRollup ||
|
|
||||||
(latentGainRollup.byClass.length === 0 &&
|
|
||||||
latentGainRollup.byVehicle.length === 0)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return latentGainRollup.grandTotal;
|
|
||||||
}, [latentGainRollup]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
|
@ -100,36 +75,6 @@ export default function BalanceOverviewCard({
|
||||||
date: formatDate(summary.latest.snapshot_date),
|
date: formatDate(summary.latest.snapshot_date),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
{latent && (
|
|
||||||
<p className="text-sm mt-2 inline-flex items-center gap-1.5">
|
|
||||||
<span className="text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.latentGain.totalLabel")}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`font-medium ${
|
|
||||||
latent.total_gain >= 0
|
|
||||||
? "text-[var(--positive)]"
|
|
||||||
: "text-[var(--negative)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{latent.total_gain >= 0 ? "+" : ""}
|
|
||||||
{cadFormatter(latent.total_gain)}
|
|
||||||
{latent.total_gain_pct !== null && (
|
|
||||||
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
|
|
||||||
({latent.total_gain >= 0 ? "+" : ""}
|
|
||||||
{(latent.total_gain_pct * 100).toFixed(2)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{latent.has_unknown_book_cost && (
|
|
||||||
<AlertTriangle
|
|
||||||
size={12}
|
|
||||||
className="text-amber-500"
|
|
||||||
aria-label={t("balance.latentGain.partial")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-2">
|
<p className="text-sm text-[var(--muted-foreground)] mt-2">
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
// DetailAccountWizard — unit tests (Issue #215).
|
|
||||||
//
|
|
||||||
// NOTE: this project has no jsdom / @testing-library harness (see
|
|
||||||
// SecurityPicker.test.ts, StarterAccountsModal.test.tsx). We exercise the pure
|
|
||||||
// toggle-payload builder — the load-bearing decision of the wizard — directly.
|
|
||||||
// DOM rendering / the confirm click are not exercised here; the wizard is thin
|
|
||||||
// orchestration over `buildDetailToggleInput` + `updateBalanceAccount`.
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { buildDetailToggleInput } from "./DetailAccountWizard";
|
|
||||||
|
|
||||||
describe("buildDetailToggleInput", () => {
|
|
||||||
it("flips kind to detailed and pins the pivot to today's local civil day", () => {
|
|
||||||
// 2026-06-06 in the local civil day (midday avoids any DST edge confusion).
|
|
||||||
const today = new Date(2026, 5, 6, 12, 30, 0);
|
|
||||||
const input = buildDetailToggleInput(today);
|
|
||||||
expect(input).toEqual({ kind: "detailed", detailed_since: "2026-06-06" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("zero-pads single-digit month and day to a YYYY-MM-DD snapshot shape", () => {
|
|
||||||
// January 2nd → "2026-01-02", matching normalizeSnapshotDate's ISO regex.
|
|
||||||
const today = new Date(2026, 0, 2, 9, 0, 0);
|
|
||||||
const input = buildDetailToggleInput(today);
|
|
||||||
expect(input.detailed_since).toBe("2026-01-02");
|
|
||||||
expect(input.kind).toBe("detailed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never emits a detailed → simple downgrade or an envelope mutation", () => {
|
|
||||||
const input = buildDetailToggleInput(new Date(2026, 11, 31, 23, 0, 0));
|
|
||||||
// Only the two pivot fields are set; vehicle_type/name/etc. stay untouched
|
|
||||||
// so updateBalanceAccount's read-and-rewrite preserves them.
|
|
||||||
expect(Object.keys(input).sort()).toEqual(["detailed_since", "kind"]);
|
|
||||||
expect(input.kind).not.toBe("simple");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
// DetailAccountWizard — light confirmation modal that flips a *simple* balance
|
|
||||||
// account to *detailed* entry mode. Issue #215 (Bilan détail par titres #5).
|
|
||||||
//
|
|
||||||
// Decision 2026-06-04 (plan-overnight): TOGGLE-ONLY. The wizard does NOT capture
|
|
||||||
// any titles. It sets `kind='detailed'` and `detailed_since = today` (the
|
|
||||||
// authoritative pivot date) via `updateBalanceAccount`. Per-security holdings
|
|
||||||
// are entered at the NEXT normal snapshot — `validateDetailedSnapshot` tolerates
|
|
||||||
// pre-pivot aggregated lines and requires holdings only at/after the pivot.
|
|
||||||
//
|
|
||||||
// Irreversibility: once holdings exist at/after the pivot, the service backstop
|
|
||||||
// (#212) rejects a `detailed → simple` downgrade with
|
|
||||||
// `account_kind_detailed_has_holdings`. The UI exposes no "simplify" action, so
|
|
||||||
// this confirmation makes the one-way nature explicit before committing.
|
|
||||||
//
|
|
||||||
// Mirrors the LinkTransfersModal idiom (createPortal overlay, stopPropagation
|
|
||||||
// inner card, i18n-only copy, WebKitGTK-safe close button).
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { X, Loader2, AlertTriangle } from "lucide-react";
|
|
||||||
import {
|
|
||||||
updateBalanceAccount,
|
|
||||||
BalanceServiceError,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
import type { UpdateBalanceAccountInput } from "../../services/balance.service";
|
|
||||||
|
|
||||||
/** Local civil-day ISO (YYYY-MM-DD) — matches stored snapshot date format. */
|
|
||||||
function localISO(d: Date): string {
|
|
||||||
const yy = d.getFullYear();
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${yy}-${mm}-${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure builder for the toggle payload — exported for unit testing. Produces the
|
|
||||||
* exact `updateBalanceAccount` input the wizard commits: flip to detailed and
|
|
||||||
* pin the pivot to `today`'s local civil day. `normalizeSnapshotDate` on the
|
|
||||||
* service side accepts this YYYY-MM-DD shape verbatim.
|
|
||||||
*/
|
|
||||||
export function buildDetailToggleInput(today: Date): UpdateBalanceAccountInput {
|
|
||||||
return { kind: "detailed", detailed_since: localISO(today) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailAccountWizardProps {
|
|
||||||
accountId: number;
|
|
||||||
accountName: string;
|
|
||||||
onClose: () => void;
|
|
||||||
/** Fired after the account was successfully flipped to detailed. */
|
|
||||||
onDetailed?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DetailAccountWizard({
|
|
||||||
accountId,
|
|
||||||
accountName,
|
|
||||||
onClose,
|
|
||||||
onDetailed,
|
|
||||||
}: DetailAccountWizardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Pivot resolved once at render: the local civil day at confirmation time.
|
|
||||||
const pivot = localISO(new Date());
|
|
||||||
|
|
||||||
async function handleConfirm() {
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await updateBalanceAccount(accountId, buildDetailToggleInput(new Date()));
|
|
||||||
onDetailed?.();
|
|
||||||
onClose();
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof BalanceServiceError) {
|
|
||||||
setError(
|
|
||||||
t(`balance.detailWizard.errors.${e.code}`, {
|
|
||||||
defaultValue: e.message,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-lg flex flex-col"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{t("balance.detailWizard.title", { account: accountName })}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 rounded hover:bg-[var(--muted)]/40"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 py-4 space-y-3 text-sm">
|
|
||||||
<p>{t("balance.detailWizard.intro")}</p>
|
|
||||||
|
|
||||||
<ul className="list-disc pl-5 space-y-1.5 text-[var(--muted-foreground)]">
|
|
||||||
<li>{t("balance.detailWizard.pointFrozen")}</li>
|
|
||||||
<li>{t("balance.detailWizard.pointNextSnapshot")}</li>
|
|
||||||
<li>{t("balance.detailWizard.pointPivot", { date: pivot })}</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[var(--foreground)]">
|
|
||||||
<AlertTriangle
|
|
||||||
size={16}
|
|
||||||
className="mt-0.5 shrink-0 text-amber-500"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span>{t("balance.detailWizard.irreversible")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-[var(--negative)]">{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={submitting}
|
|
||||||
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={submitting}
|
|
||||||
className="px-3 py-1.5 text-sm rounded bg-[var(--primary)] text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Loader2 className="animate-spin" size={14} />
|
|
||||||
{t("balance.detailWizard.confirming")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t("balance.detailWizard.confirm")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,351 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
// SecurityPicker — unit tests (Issue #214).
|
|
||||||
//
|
|
||||||
// NOTE: this project has no jsdom / @testing-library harness (logged across
|
|
||||||
// the balance UI work). We test the picker's pure decision logic — the
|
|
||||||
// filter and the create-vs-pick rule — directly, the same way
|
|
||||||
// CategoryCombobox.test.ts tests `sortHierarchical`. DOM rendering is not
|
|
||||||
// exercised here.
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { filterSecurities, decideCreateOption } from "./SecurityPicker";
|
|
||||||
import type { BalanceSecurity } from "../../shared/types";
|
|
||||||
|
|
||||||
function sec(
|
|
||||||
id: number,
|
|
||||||
symbol: string,
|
|
||||||
name: string | null,
|
|
||||||
asset_type: "stock" | "crypto" = "stock"
|
|
||||||
): BalanceSecurity {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
symbol,
|
|
||||||
name,
|
|
||||||
currency: "CAD",
|
|
||||||
asset_type,
|
|
||||||
created_at: "",
|
|
||||||
updated_at: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalogue: BalanceSecurity[] = [
|
|
||||||
sec(1, "AAPL", "Apple Inc."),
|
|
||||||
sec(2, "BTC", "Bitcoin", "crypto"),
|
|
||||||
sec(3, "ETH", "Ethereum", "crypto"),
|
|
||||||
sec(4, "MSFT", "Microsoft Corp."),
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("filterSecurities", () => {
|
|
||||||
it("returns the whole catalogue for an empty / whitespace query", () => {
|
|
||||||
expect(filterSecurities(catalogue, "")).toHaveLength(4);
|
|
||||||
expect(filterSecurities(catalogue, " ")).toHaveLength(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches on symbol, case-insensitively", () => {
|
|
||||||
const r = filterSecurities(catalogue, "aapl");
|
|
||||||
expect(r.map((s) => s.symbol)).toEqual(["AAPL"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches on name, case-insensitively", () => {
|
|
||||||
const r = filterSecurities(catalogue, "micro");
|
|
||||||
expect(r.map((s) => s.symbol)).toEqual(["MSFT"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches a partial substring across multiple rows", () => {
|
|
||||||
// "et" hits ETH's symbol and nothing else here.
|
|
||||||
const r = filterSecurities(catalogue, "eth");
|
|
||||||
expect(r.map((s) => s.symbol)).toEqual(["ETH"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the catalogue order of matches", () => {
|
|
||||||
const r = filterSecurities(catalogue, "t"); // BTC, ETH, MSFT all contain 't'
|
|
||||||
expect(r.map((s) => s.symbol)).toEqual(["BTC", "ETH", "MSFT"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [] when nothing matches", () => {
|
|
||||||
expect(filterSecurities(catalogue, "ZZZZ")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decideCreateOption", () => {
|
|
||||||
it("returns null for an empty / whitespace query (nothing to create)", () => {
|
|
||||||
expect(decideCreateOption(catalogue, "")).toBeNull();
|
|
||||||
expect(decideCreateOption(catalogue, " ")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers a normalized (UPPER/TRIM) create for a brand-new symbol", () => {
|
|
||||||
expect(decideCreateOption(catalogue, " tsla ")).toEqual({ symbol: "TSLA" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the normalized symbol already exists (exact match)", () => {
|
|
||||||
// Same symbol, different casing/whitespace — already in the catalogue, so
|
|
||||||
// there is nothing new to create.
|
|
||||||
expect(decideCreateOption(catalogue, "aapl")).toBeNull();
|
|
||||||
expect(decideCreateOption(catalogue, " AAPL ")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still offers create when the query only PARTIALLY matches an existing symbol", () => {
|
|
||||||
// "AAP" is a prefix of AAPL but not an exact symbol — the user may want a
|
|
||||||
// distinct ticker, so the create option is offered.
|
|
||||||
expect(decideCreateOption(catalogue, "AAP")).toEqual({ symbol: "AAP" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
// SecurityPicker — autocomplete over the existing `balance_securities` catalogue
|
|
||||||
// with inline creation (Issue #214 / Bilan détail par titre).
|
|
||||||
//
|
|
||||||
// Behavior (decisions logged in the autopilot run of 2026-06-04 / 2026-06-06):
|
|
||||||
// - The input accepts ANY normalized string (UPPER + TRIM). There is NO live
|
|
||||||
// symbol validation — the price fetch (PriceFetchControl) is a separate,
|
|
||||||
// best-effort step. A symbol the user types but that does not exist in the
|
|
||||||
// catalogue is offered as an inline "create" option.
|
|
||||||
// - Picking an existing security emits its stored `symbol` + `asset_type`
|
|
||||||
// (+ optional `name`). Creating a new symbol emits the typed (normalized)
|
|
||||||
// symbol + the asset_type chosen via the stock/crypto toggle (default
|
|
||||||
// 'stock', matching `makeEmptyHolding`).
|
|
||||||
// - The symbol format mirrors the price-fetching one: `normalizeSecuritySymbol`
|
|
||||||
// (UPPER(TRIM(...))), the exact function the service + migrations use, so a
|
|
||||||
// picker-created security collapses onto the same `balance_securities` row.
|
|
||||||
//
|
|
||||||
// The UI idiom follows `CategoryCombobox` (controlled input + listbox, keyboard
|
|
||||||
// nav, click-outside close) so the Bilan editor stays visually consistent.
|
|
||||||
//
|
|
||||||
// This component is presentation + selection only. It receives the catalogue
|
|
||||||
// (the parent loads it once via `listSecurities()`), the current row symbol,
|
|
||||||
// and emits a `SecurityPick` on choose/create. Persisting a brand-new security
|
|
||||||
// happens server-side at save time (`findOrCreateSecurity` inside the atomic
|
|
||||||
// save), so no DB write happens here.
|
|
||||||
|
|
||||||
import {
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { BalanceAssetType, BalanceSecurity } from "../../shared/types";
|
|
||||||
import { normalizeSecuritySymbol } from "../../services/balance.service";
|
|
||||||
|
|
||||||
/** What the picker emits when the user selects or creates a security. */
|
|
||||||
export interface SecurityPick {
|
|
||||||
/** Normalized (UPPER/TRIM) symbol. */
|
|
||||||
symbol: string;
|
|
||||||
asset_type: BalanceAssetType;
|
|
||||||
/** Existing security's name, or null for a freshly-created symbol. */
|
|
||||||
name: string | null;
|
|
||||||
/** True when the symbol is not (yet) in the catalogue — created inline. */
|
|
||||||
isNew: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecurityPickerProps {
|
|
||||||
/** The full securities catalogue (loaded once by the parent). */
|
|
||||||
securities: BalanceSecurity[];
|
|
||||||
/** Currently selected symbol on this row (controlled). */
|
|
||||||
value: string;
|
|
||||||
/** Asset type currently on the row — seeds the create toggle default. */
|
|
||||||
assetType: BalanceAssetType;
|
|
||||||
onSelect: (pick: SecurityPick) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
ariaLabel?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pure helpers (exported for unit tests — the project has no jsdom harness, so
|
|
||||||
// component logic is tested through these rather than via DOM rendering).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter the catalogue by a raw query. Matching is case-insensitive over both
|
|
||||||
* the symbol and the (optional) name. An empty query returns the whole list.
|
|
||||||
* The catalogue is assumed already symbol-sorted (listSecurities orders by
|
|
||||||
* symbol); we preserve that order.
|
|
||||||
*/
|
|
||||||
export function filterSecurities(
|
|
||||||
securities: BalanceSecurity[],
|
|
||||||
query: string
|
|
||||||
): BalanceSecurity[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (q.length === 0) return securities;
|
|
||||||
return securities.filter((s) => {
|
|
||||||
if (s.symbol.toLowerCase().includes(q)) return true;
|
|
||||||
if (s.name && s.name.toLowerCase().includes(q)) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide, for a typed query, whether an inline "create" option should be
|
|
||||||
* offered and what it would create. Returns null when the query is empty or
|
|
||||||
* when an EXACT (normalized) symbol match already exists in the catalogue —
|
|
||||||
* in that case there is nothing new to create. The create symbol is the
|
|
||||||
* normalized form so it round-trips to the same `balance_securities` row.
|
|
||||||
*/
|
|
||||||
export function decideCreateOption(
|
|
||||||
securities: BalanceSecurity[],
|
|
||||||
query: string
|
|
||||||
): { symbol: string } | null {
|
|
||||||
const normalized = normalizeSecuritySymbol(query);
|
|
||||||
if (normalized.length === 0) return null;
|
|
||||||
const exact = securities.some(
|
|
||||||
(s) => normalizeSecuritySymbol(s.symbol) === normalized
|
|
||||||
);
|
|
||||||
if (exact) return null;
|
|
||||||
return { symbol: normalized };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SecurityPicker({
|
|
||||||
securities,
|
|
||||||
value,
|
|
||||||
assetType,
|
|
||||||
onSelect,
|
|
||||||
disabled,
|
|
||||||
ariaLabel,
|
|
||||||
placeholder,
|
|
||||||
}: SecurityPickerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
||||||
// Asset type to use when CREATING a new symbol. Seeded from the row's current
|
|
||||||
// asset type (default 'stock' via makeEmptyHolding); the user can flip it.
|
|
||||||
const [createAssetType, setCreateAssetType] =
|
|
||||||
useState<BalanceAssetType>(assetType);
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const baseId = useId();
|
|
||||||
const listboxId = `${baseId}-listbox`;
|
|
||||||
const optionId = (i: number) => `${baseId}-option-${i}`;
|
|
||||||
|
|
||||||
// Keep the create-toggle default in sync if the row's asset type changes
|
|
||||||
// externally (e.g. a different security was picked then cleared).
|
|
||||||
useEffect(() => {
|
|
||||||
setCreateAssetType(assetType);
|
|
||||||
}, [assetType]);
|
|
||||||
|
|
||||||
const filtered = useMemo(
|
|
||||||
() => filterSecurities(securities, query),
|
|
||||||
[securities, query]
|
|
||||||
);
|
|
||||||
const createOption = useMemo(
|
|
||||||
() => decideCreateOption(securities, query),
|
|
||||||
[securities, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Layout: [existing matches...] then (optionally) the create row last.
|
|
||||||
const totalItems = filtered.length + (createOption ? 1 : 0);
|
|
||||||
const createIndex = createOption ? filtered.length : -1;
|
|
||||||
|
|
||||||
// The text shown in the input when closed: the selected symbol verbatim.
|
|
||||||
const displayLabel = value;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && listRef.current) {
|
|
||||||
const el = listRef.current.children[highlightIndex] as
|
|
||||||
| HTMLElement
|
|
||||||
| undefined;
|
|
||||||
el?.scrollIntoView({ block: "nearest" });
|
|
||||||
}
|
|
||||||
}, [highlightIndex, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
containerRef.current &&
|
|
||||||
!containerRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
|
||||||
setQuery("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const choosePick = useCallback(
|
|
||||||
(pick: SecurityPick) => {
|
|
||||||
onSelect(pick);
|
|
||||||
setOpen(false);
|
|
||||||
setQuery("");
|
|
||||||
inputRef.current?.blur();
|
|
||||||
},
|
|
||||||
[onSelect]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectItem = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === createIndex && createOption) {
|
|
||||||
choosePick({
|
|
||||||
symbol: createOption.symbol,
|
|
||||||
asset_type: createAssetType,
|
|
||||||
name: null,
|
|
||||||
isNew: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sec = filtered[index];
|
|
||||||
if (sec) {
|
|
||||||
choosePick({
|
|
||||||
symbol: sec.symbol,
|
|
||||||
asset_type: sec.asset_type,
|
|
||||||
name: sec.name,
|
|
||||||
isNew: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filtered, createIndex, createOption, createAssetType, choosePick]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (!open) {
|
|
||||||
if (e.key === "ArrowDown" || e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(true);
|
|
||||||
setHighlightIndex(0);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (e.key) {
|
|
||||||
case "ArrowDown":
|
|
||||||
e.preventDefault();
|
|
||||||
if (totalItems > 0)
|
|
||||||
setHighlightIndex((i) => (i + 1) % totalItems);
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
e.preventDefault();
|
|
||||||
if (totalItems > 0)
|
|
||||||
setHighlightIndex((i) => (i - 1 + totalItems) % totalItems);
|
|
||||||
break;
|
|
||||||
case "Enter":
|
|
||||||
e.preventDefault();
|
|
||||||
if (totalItems > 0) selectItem(highlightIndex);
|
|
||||||
break;
|
|
||||||
case "Escape":
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(false);
|
|
||||||
setQuery("");
|
|
||||||
inputRef.current?.blur();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeId =
|
|
||||||
open && totalItems > 0 ? optionId(highlightIndex) : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative w-40">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
role="combobox"
|
|
||||||
aria-label={ariaLabel ?? t("balance.snapshot.detailed.symbolLabel")}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={listboxId}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-activedescendant={activeId}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
disabled={disabled}
|
|
||||||
value={open ? query : displayLabel}
|
|
||||||
placeholder={
|
|
||||||
placeholder ?? t("balance.snapshot.detailed.picker.placeholder")
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setHighlightIndex(0);
|
|
||||||
if (!open) setOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
setOpen(true);
|
|
||||||
setQuery("");
|
|
||||||
setHighlightIndex(0);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="w-full px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute z-50 mt-1 w-72 rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg overflow-hidden">
|
|
||||||
{/* Asset-type toggle for the create option (stock / crypto). Only
|
|
||||||
meaningful when a new symbol would be created; shown alongside it
|
|
||||||
so the user sets the class before committing the create. */}
|
|
||||||
{createOption && (
|
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-[var(--muted)]/40">
|
|
||||||
<span className="text-[11px] text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.snapshot.detailed.picker.assetTypeLabel")}
|
|
||||||
</span>
|
|
||||||
<div className="flex rounded-md overflow-hidden border border-[var(--border)]">
|
|
||||||
{(["stock", "crypto"] as const).map((at) => (
|
|
||||||
<button
|
|
||||||
key={at}
|
|
||||||
type="button"
|
|
||||||
// Prevent the input blur (mousedown) from closing the list.
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setCreateAssetType(at)}
|
|
||||||
className={`px-2 py-0.5 text-[11px] ${
|
|
||||||
createAssetType === at
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
|
||||||
}`}
|
|
||||||
aria-pressed={createAssetType === at}
|
|
||||||
>
|
|
||||||
{t(`balance.snapshot.detailed.picker.assetType.${at}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalItems === 0 ? (
|
|
||||||
<p className="px-3 py-2 text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.snapshot.detailed.picker.empty")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul
|
|
||||||
ref={listRef}
|
|
||||||
id={listboxId}
|
|
||||||
role="listbox"
|
|
||||||
className="max-h-56 overflow-auto"
|
|
||||||
>
|
|
||||||
{filtered.map((sec, i) => (
|
|
||||||
<li
|
|
||||||
key={sec.id}
|
|
||||||
id={optionId(i)}
|
|
||||||
role="option"
|
|
||||||
aria-selected={i === highlightIndex}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => selectItem(i)}
|
|
||||||
onMouseEnter={() => setHighlightIndex(i)}
|
|
||||||
className={`flex items-center justify-between gap-2 px-3 py-1.5 text-sm cursor-pointer ${
|
|
||||||
i === highlightIndex
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1.5 min-w-0">
|
|
||||||
<span className="font-medium">{sec.symbol}</span>
|
|
||||||
{sec.name && (
|
|
||||||
<span
|
|
||||||
className={`truncate text-xs ${
|
|
||||||
i === highlightIndex
|
|
||||||
? "text-white/80"
|
|
||||||
: "text-[var(--muted-foreground)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sec.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`shrink-0 text-[10px] uppercase tracking-wide ${
|
|
||||||
i === highlightIndex
|
|
||||||
? "text-white/80"
|
|
||||||
: "text-[var(--muted-foreground)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
`balance.snapshot.detailed.picker.assetType.${sec.asset_type}`
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{createOption && (
|
|
||||||
<li
|
|
||||||
id={optionId(createIndex)}
|
|
||||||
role="option"
|
|
||||||
aria-selected={createIndex === highlightIndex}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => selectItem(createIndex)}
|
|
||||||
onMouseEnter={() => setHighlightIndex(createIndex)}
|
|
||||||
className={`px-3 py-1.5 text-sm cursor-pointer border-t border-[var(--border)] ${
|
|
||||||
createIndex === highlightIndex
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("balance.snapshot.detailed.picker.create", {
|
|
||||||
symbol: createOption.symbol,
|
|
||||||
})}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +1,29 @@
|
||||||
// SnapshotEditor — groups the active accounts by balance category and
|
// SnapshotEditor — groups the active accounts by balance category and
|
||||||
// renders one `SnapshotLineRow` per account.
|
// renders one `SnapshotLineRow` per account.
|
||||||
//
|
//
|
||||||
// Each row dispatches its variant on the account's OWN `account.kind` (#213)
|
// Both `simple` and `priced` variants are dispatched by `account.category_kind`
|
||||||
// inside `SnapshotLineRow` (simple → scalar value; detailed → holdings basket).
|
// inside `SnapshotLineRow`. The editor itself only carries the values down
|
||||||
// The editor itself only carries the values/holdings down and the change
|
// and the change handlers up.
|
||||||
// handlers up.
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
BalanceAccountWithCategory,
|
BalanceAccountWithCategory,
|
||||||
BalanceCategory,
|
BalanceCategory,
|
||||||
BalanceSecurity,
|
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
|
||||||
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
|
|
||||||
import type { SecurityPick } from "./SecurityPicker";
|
|
||||||
import SnapshotLineRow from "./SnapshotLineRow";
|
import SnapshotLineRow from "./SnapshotLineRow";
|
||||||
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accounts: BalanceAccountWithCategory[];
|
accounts: BalanceAccountWithCategory[];
|
||||||
categories: BalanceCategory[];
|
categories: BalanceCategory[];
|
||||||
/** account_id → string-typed value (simple accounts). */
|
/** account_id → string-typed value (simple kind). */
|
||||||
values: Record<number, string>;
|
values: Record<number, string>;
|
||||||
/** account_id → holdings basket (detailed accounts, #213). */
|
/** account_id → {quantity, unit_price} strings (priced kind). */
|
||||||
holdings: Record<number, HoldingDraft[]>;
|
pricedValues: Record<number, PricedEntry>;
|
||||||
/** Securities catalogue for the SecurityPicker autocomplete (#214). */
|
|
||||||
securities: BalanceSecurity[];
|
|
||||||
onValueChange: (accountId: number, next: string) => void;
|
onValueChange: (accountId: number, next: string) => void;
|
||||||
onAddHolding: (accountId: number, assetType?: "stock" | "crypto") => void;
|
onQuantityChange: (accountId: number, next: string) => void;
|
||||||
onRemoveHolding: (accountId: number, rowId: string) => void;
|
onUnitPriceChange: (accountId: number, next: string) => void;
|
||||||
onHoldingFieldChange: (
|
|
||||||
accountId: number,
|
|
||||||
rowId: string,
|
|
||||||
field: keyof Omit<HoldingDraft, "rowId">,
|
|
||||||
value: string
|
|
||||||
) => void;
|
|
||||||
/** Apply a SecurityPicker selection to a holding row (#214). */
|
|
||||||
onHoldingSecurityPick: (
|
|
||||||
accountId: number,
|
|
||||||
rowId: string,
|
|
||||||
pick: SecurityPick
|
|
||||||
) => 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,14 +33,10 @@ export default function SnapshotEditor({
|
||||||
accounts,
|
accounts,
|
||||||
categories,
|
categories,
|
||||||
values,
|
values,
|
||||||
holdings,
|
pricedValues,
|
||||||
securities,
|
|
||||||
onValueChange,
|
onValueChange,
|
||||||
onAddHolding,
|
onQuantityChange,
|
||||||
onRemoveHolding,
|
onUnitPriceChange,
|
||||||
onHoldingFieldChange,
|
|
||||||
onHoldingSecurityPick,
|
|
||||||
onImportHoldings,
|
|
||||||
disabled,
|
disabled,
|
||||||
snapshotDate,
|
snapshotDate,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
@ -110,35 +81,27 @@ export default function SnapshotEditor({
|
||||||
>
|
>
|
||||||
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
|
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
{renderCategoryLabelFromCategory(category, t)}
|
{t(category.i18n_key, { defaultValue: category.key })}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
{catAccounts.map((acc) => (
|
{catAccounts.map((acc) => {
|
||||||
<SnapshotLineRow
|
const priced = pricedValues[acc.id];
|
||||||
key={acc.id}
|
return (
|
||||||
account={acc}
|
<SnapshotLineRow
|
||||||
value={values[acc.id] ?? ""}
|
key={acc.id}
|
||||||
holdings={holdings[acc.id]}
|
account={acc}
|
||||||
securities={securities}
|
value={values[acc.id] ?? ""}
|
||||||
onChange={(next) => onValueChange(acc.id, next)}
|
quantityValue={priced?.quantity ?? ""}
|
||||||
onAddHolding={() =>
|
unitPriceValue={priced?.unit_price ?? ""}
|
||||||
onAddHolding(acc.id, acc.category_asset_type ?? "stock")
|
onChange={(next) => onValueChange(acc.id, next)}
|
||||||
}
|
onQuantityChange={(next) => onQuantityChange(acc.id, next)}
|
||||||
onRemoveHolding={(rowId) => onRemoveHolding(acc.id, rowId)}
|
onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)}
|
||||||
onHoldingFieldChange={(rowId, field, value) =>
|
disabled={disabled}
|
||||||
onHoldingFieldChange(acc.id, rowId, field, value)
|
snapshotDate={snapshotDate}
|
||||||
}
|
/>
|
||||||
onHoldingSecurityPick={(rowId, pick) =>
|
);
|
||||||
onHoldingSecurityPick(acc.id, rowId, pick)
|
})}
|
||||||
}
|
|
||||||
onImportHoldings={(rows, mapping) =>
|
|
||||||
onImportHoldings(acc.id, rows, mapping)
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
snapshotDate={snapshotDate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,48 @@
|
||||||
// SnapshotLineRow — single account line inside the snapshot editor.
|
// SnapshotLineRow — single account line inside the snapshot editor.
|
||||||
//
|
//
|
||||||
// Two variants are dispatched by the account's OWN `account.kind` (#213),
|
// Two variants are dispatched by `account.category_kind`:
|
||||||
// NOT by `category_kind`:
|
|
||||||
//
|
//
|
||||||
// - `simple` (Issue #146): a single value input keyed by `account_id`.
|
// - `simple` (Issue #146): a single value input keyed by `account_id`.
|
||||||
// - `detailed` (Issue #213): N sub-rows, one per security held — each with
|
// - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both
|
||||||
// `quantity`, `unit_price` (both required), a read-only live
|
// required), and a read-only `value` field that
|
||||||
// `value`, and the existing PriceFetchControl. The account's
|
// renders `quantity * unit_price` live as the
|
||||||
// value is the sum across its holdings.
|
// user types. An attribution tag `[Manuel]`
|
||||||
|
// appears next to the row; the `[via Maximus]`
|
||||||
|
// tag is rendered by PriceFetchControl (Issue #158).
|
||||||
//
|
//
|
||||||
// The OLD "priced scalar" variant (one security via account.symbol + scalar
|
// We keep this component dumb on purpose: it receives strings from the
|
||||||
// quantity/unit_price on the line) is SUPERSEDED: migration v16 (#211)
|
// parent (the editor stores raw strings to preserve partial input) and
|
||||||
// converted every former-priced account into `kind='detailed'` with one
|
// emits new strings on every change. Numeric validation happens at save
|
||||||
// holding, so those accounts now flow through the detailed (holdings) path.
|
// time in `useSnapshotEditor.save` against the service's
|
||||||
//
|
// `validateLineKindInvariants` helper.
|
||||||
// #214 turns the detailed variant into the real per-title entry surface: each
|
|
||||||
// sub-row carries a SecurityPicker (autocomplete over `balance_securities` +
|
|
||||||
// inline creation), quantity, unit_price (+ price fetch), a read-only computed
|
|
||||||
// value, a book_cost input, and a live latent-gain figure. The account's value
|
|
||||||
// is the SUM across its holdings.
|
|
||||||
//
|
|
||||||
// We keep this component dumb on purpose: it receives strings from the parent
|
|
||||||
// (the editor stores raw strings to preserve partial input) and emits new
|
|
||||||
// strings on every change. Numeric validation happens at save time in
|
|
||||||
// `useSnapshotEditor.save`.
|
|
||||||
|
|
||||||
import { ChangeEvent, useMemo, useState } from "react";
|
import { ChangeEvent, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, Trash2, Upload } from "lucide-react";
|
import type { BalanceAccountWithCategory } from "../../shared/types";
|
||||||
import type {
|
|
||||||
BalanceAccountWithCategory,
|
|
||||||
BalanceSecurity,
|
|
||||||
} from "../../shared/types";
|
|
||||||
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 HoldingsCsvImportModal from "./HoldingsCsvImportModal";
|
|
||||||
|
|
||||||
interface Props {
|
interface BaseProps {
|
||||||
account: BalanceAccountWithCategory;
|
account: BalanceAccountWithCategory;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */
|
/** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */
|
||||||
snapshotDate?: string;
|
snapshotDate?: string;
|
||||||
/** Simple variant: the scalar value string + its change handler. */
|
}
|
||||||
|
|
||||||
|
interface SimpleProps extends BaseProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
/** Detailed variant: the holdings basket + mutators (#213). */
|
/** Optional priced handlers for callers that wire both at once. */
|
||||||
holdings?: HoldingDraft[];
|
quantityValue?: string;
|
||||||
/** Securities catalogue for the SecurityPicker autocomplete (#214). */
|
unitPriceValue?: string;
|
||||||
securities?: BalanceSecurity[];
|
onQuantityChange?: (next: string) => void;
|
||||||
onAddHolding?: () => void;
|
onUnitPriceChange?: (next: string) => void;
|
||||||
onRemoveHolding?: (rowId: string) => void;
|
|
||||||
onHoldingFieldChange?: (
|
|
||||||
rowId: string,
|
|
||||||
field: keyof Omit<HoldingDraft, "rowId">,
|
|
||||||
value: string
|
|
||||||
) => void;
|
|
||||||
/** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Props = SimpleProps;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a string like "12.34" or "12,34" into a finite number, or null
|
* Parse a string like "12.34" or "12,34" into a finite number, or null
|
||||||
* if invalid / empty. Used by the detailed sub-rows to compute the live
|
* if invalid / empty. Used by the priced variant to compute the live
|
||||||
* `value` preview.
|
* `value` preview.
|
||||||
*/
|
*/
|
||||||
function parseDecimal(raw: string): number | null {
|
function parseDecimal(raw: string): number | null {
|
||||||
|
|
@ -81,113 +58,129 @@ export default function SnapshotLineRow({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
|
quantityValue,
|
||||||
|
unitPriceValue,
|
||||||
|
onQuantityChange,
|
||||||
|
onUnitPriceChange,
|
||||||
snapshotDate,
|
snapshotDate,
|
||||||
holdings,
|
|
||||||
securities,
|
|
||||||
onAddHolding,
|
|
||||||
onRemoveHolding,
|
|
||||||
onHoldingFieldChange,
|
|
||||||
onHoldingSecurityPick,
|
|
||||||
onImportHoldings,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDetailed = account.kind === "detailed";
|
const isPriced = account.category_kind === "priced";
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
|
|
||||||
// Account total across the basket (live as the user types).
|
// Compute the live value preview for priced rows. Returns null when
|
||||||
const detailedTotal = useMemo(() => {
|
// either input cannot yet be parsed (so we display a placeholder).
|
||||||
if (!isDetailed || !holdings) return null;
|
const computedPricedValue = useMemo(() => {
|
||||||
let total = 0;
|
if (!isPriced) return null;
|
||||||
for (const h of holdings) {
|
const qty = parseDecimal(quantityValue ?? "");
|
||||||
const qty = parseDecimal(h.quantity);
|
const price = parseDecimal(unitPriceValue ?? "");
|
||||||
const price = parseDecimal(h.unit_price);
|
if (qty === null || price === null) return null;
|
||||||
if (qty !== null && price !== null) total += qty * price;
|
return qty * price;
|
||||||
}
|
}, [isPriced, quantityValue, unitPriceValue]);
|
||||||
return total;
|
|
||||||
}, [isDetailed, holdings]);
|
if (isPriced) {
|
||||||
|
const handleQty = (e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onQuantityChange?.(e.target.value);
|
||||||
|
const handlePrice = (e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onUnitPriceChange?.(e.target.value);
|
||||||
|
|
||||||
if (isDetailed) {
|
|
||||||
const rows = holdings ?? [];
|
|
||||||
return (
|
return (
|
||||||
<div className="py-2 border-b border-[var(--border)] last:border-b-0">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium truncate">{account.name}</span>
|
<span className="text-sm font-medium truncate">{account.name}</span>
|
||||||
<span
|
<span
|
||||||
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
|
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||||
title={t("balance.snapshot.detailed.badgeHint")}
|
title={t("balance.snapshot.priced.attributionManualHint")}
|
||||||
>
|
>
|
||||||
{t("balance.snapshot.detailed.badge")}
|
{t("balance.snapshot.priced.attributionManual")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{detailedTotal !== null && (
|
{account.symbol && (
|
||||||
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
{detailedTotal.toLocaleString(undefined, {
|
{account.symbol}
|
||||||
minimumFractionDigits: 2,
|
</div>
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}{" "}
|
|
||||||
{account.currency}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{rows.length === 0 ? (
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-xs text-[var(--muted-foreground)] py-1">
|
<input
|
||||||
{t("balance.snapshot.detailed.empty")}
|
type="text"
|
||||||
</p>
|
inputMode="decimal"
|
||||||
) : (
|
value={quantityValue ?? ""}
|
||||||
<div className="flex flex-col gap-2">
|
onChange={handleQty}
|
||||||
{rows.map((h) => (
|
|
||||||
<HoldingSubRow
|
|
||||||
key={h.rowId}
|
|
||||||
holding={h}
|
|
||||||
accountName={account.name}
|
|
||||||
accountCurrency={account.currency}
|
|
||||||
securities={securities ?? []}
|
|
||||||
snapshotDate={snapshotDate}
|
|
||||||
disabled={disabled}
|
|
||||||
onFieldChange={(field, v) =>
|
|
||||||
onHoldingFieldChange?.(h.rowId, field, v)
|
|
||||||
}
|
|
||||||
onSecurityPick={(pick) =>
|
|
||||||
onHoldingSecurityPick?.(h.rowId, pick)
|
|
||||||
}
|
|
||||||
onRemove={() => onRemoveHolding?.(h.rowId)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onAddHolding?.()}
|
|
||||||
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")}
|
|
||||||
</button>
|
|
||||||
{onImportHoldings && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setImportOpen(true)}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
|
||||||
>
|
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||||||
<Upload size={13} />
|
aria-label={t("balance.snapshot.priced.quantityLabel", {
|
||||||
{t("balance.snapshot.detailed.importCsv.button")}
|
account: account.name,
|
||||||
</button>
|
})}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||||||
|
{t("balance.snapshot.priced.quantity")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={unitPriceValue ?? ""}
|
||||||
|
onChange={handlePrice}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
|
||||||
|
className="w-28 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||||||
|
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
|
||||||
|
account: account.name,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||||||
|
{t("balance.snapshot.priced.unitPrice")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||||||
|
=
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={
|
||||||
|
computedPricedValue === null
|
||||||
|
? ""
|
||||||
|
: computedPricedValue.toFixed(2)
|
||||||
|
}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
||||||
|
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] focus:outline-none cursor-not-allowed"
|
||||||
|
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
||||||
|
account: account.name,
|
||||||
|
})}
|
||||||
|
aria-readonly="true"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||||||
|
{t("balance.snapshot.priced.computedValue")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
||||||
|
{account.currency}
|
||||||
|
</span>
|
||||||
|
{/* PriceFetchControl — wired next to the unit_price input.
|
||||||
|
Hidden when category_asset_type is null (legacy custom priced
|
||||||
|
rows pre-#169 migration; user must edit the category to set it). */}
|
||||||
|
{account.symbol && account.category_asset_type && (
|
||||||
|
<PriceFetchControl
|
||||||
|
symbol={account.symbol}
|
||||||
|
date={snapshotDate ?? ""}
|
||||||
|
categoryKind={account.category_kind as "priced"}
|
||||||
|
assetType={account.category_asset_type}
|
||||||
|
onPriceFetched={(price) =>
|
||||||
|
onUnitPriceChange?.(String(price))
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{importOpen && onImportHoldings && (
|
|
||||||
<HoldingsCsvImportModal
|
|
||||||
accountName={account.name}
|
|
||||||
onImport={onImportHoldings}
|
|
||||||
onClose={() => setImportOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -227,202 +220,3 @@ export default function SnapshotLineRow({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Detailed sub-row — one security position (#214: SecurityPicker + polished
|
|
||||||
// columns [titre, quantité, cours (+ fetch), valeur, book_cost, gain latent]).
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** A small labeled field wrapper so each column reads clearly on its own. */
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoldingSubRow({
|
|
||||||
holding,
|
|
||||||
accountName,
|
|
||||||
accountCurrency,
|
|
||||||
securities,
|
|
||||||
snapshotDate,
|
|
||||||
disabled,
|
|
||||||
onFieldChange,
|
|
||||||
onSecurityPick,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
holding: HoldingDraft;
|
|
||||||
accountName: string;
|
|
||||||
accountCurrency: string;
|
|
||||||
securities: BalanceSecurity[];
|
|
||||||
snapshotDate?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onFieldChange: (field: keyof Omit<HoldingDraft, "rowId">, value: string) => void;
|
|
||||||
onSecurityPick: (pick: SecurityPick) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const computedValue = useMemo(() => {
|
|
||||||
const qty = parseDecimal(holding.quantity);
|
|
||||||
const price = parseDecimal(holding.unit_price);
|
|
||||||
if (qty === null || price === null) return null;
|
|
||||||
return qty * price;
|
|
||||||
}, [holding.quantity, holding.unit_price]);
|
|
||||||
|
|
||||||
// Live latent gain = value − book_cost. N/A when value can't be computed or
|
|
||||||
// book_cost is empty / zero (consistent with computeUnrealizedGain's guard,
|
|
||||||
// which treats a 0 book_cost as "no meaningful gain figure" for display).
|
|
||||||
const latentGain = useMemo(() => {
|
|
||||||
if (computedValue === null) return null;
|
|
||||||
const bookCost = parseDecimal(holding.book_cost);
|
|
||||||
if (bookCost === null || bookCost === 0) return null;
|
|
||||||
return computedValue - bookCost;
|
|
||||||
}, [computedValue, holding.book_cost]);
|
|
||||||
|
|
||||||
const label = holding.symbol || accountName;
|
|
||||||
const fmt2 = (n: number) =>
|
|
||||||
n.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-end gap-2 pl-2 py-1 border-l-2 border-[var(--border)]">
|
|
||||||
{/* Titre — SecurityPicker (autocomplete + inline create) */}
|
|
||||||
<Field label={t("balance.snapshot.detailed.col.title")}>
|
|
||||||
<SecurityPicker
|
|
||||||
securities={securities}
|
|
||||||
value={holding.symbol}
|
|
||||||
assetType={holding.asset_type}
|
|
||||||
onSelect={onSecurityPick}
|
|
||||||
disabled={disabled}
|
|
||||||
ariaLabel={t("balance.snapshot.detailed.symbolLabel")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Quantité */}
|
|
||||||
<Field label={t("balance.snapshot.detailed.col.quantity")}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={holding.quantity}
|
|
||||||
onChange={(e) => onFieldChange("quantity", e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
|
|
||||||
className="w-20 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
||||||
aria-label={t("balance.snapshot.priced.quantityLabel", {
|
|
||||||
account: label,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Cours (unit price) */}
|
|
||||||
<Field label={t("balance.snapshot.detailed.col.unitPrice")}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={holding.unit_price}
|
|
||||||
onChange={(e) => onFieldChange("unit_price", e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
|
|
||||||
className="w-24 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
||||||
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
|
|
||||||
account: label,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Valeur (computed, read-only) */}
|
|
||||||
<Field label={t("balance.snapshot.detailed.col.value")}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={computedValue === null ? "" : computedValue.toFixed(2)}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
|
||||||
className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] cursor-not-allowed"
|
|
||||||
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
|
||||||
account: label,
|
|
||||||
})}
|
|
||||||
aria-readonly="true"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Book cost (cost basis) */}
|
|
||||||
<Field label={t("balance.snapshot.detailed.col.bookCost")}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={holding.book_cost}
|
|
||||||
onChange={(e) => onFieldChange("book_cost", e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={t("balance.snapshot.detailed.bookCostPlaceholder")}
|
|
||||||
className="w-24 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
||||||
aria-label={t("balance.snapshot.detailed.bookCostLabel", {
|
|
||||||
account: label,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Gain latent (value − book_cost), live, read-only */}
|
|
||||||
<Field label={t("balance.snapshot.detailed.col.latentGain")}>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center justify-end w-24 px-2 py-1.5 text-sm text-right tabular-nums ${
|
|
||||||
latentGain === null
|
|
||||||
? "text-[var(--muted-foreground)]"
|
|
||||||
: latentGain >= 0
|
|
||||||
? "text-[var(--positive)]"
|
|
||||||
: "text-[var(--negative)]"
|
|
||||||
}`}
|
|
||||||
aria-label={t("balance.snapshot.detailed.latentGainLabel", {
|
|
||||||
account: label,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{latentGain === null
|
|
||||||
? t("balance.snapshot.detailed.latentGainNA")
|
|
||||||
: `${latentGain >= 0 ? "+" : ""}${fmt2(latentGain)}`}
|
|
||||||
</span>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)] pb-2">
|
|
||||||
{accountCurrency}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{holding.symbol && (
|
|
||||||
<div className="pb-0.5">
|
|
||||||
<PriceFetchControl
|
|
||||||
symbol={holding.symbol}
|
|
||||||
date={snapshotDate ?? ""}
|
|
||||||
categoryKind={"priced"}
|
|
||||||
assetType={holding.asset_type}
|
|
||||||
onPriceFetched={(price) =>
|
|
||||||
onFieldChange("unit_price", String(price))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
disabled={disabled}
|
|
||||||
className="p-1 mb-1 rounded text-[var(--muted-foreground)] hover:text-[var(--negative)] hover:bg-[var(--negative)]/10 disabled:opacity-50"
|
|
||||||
title={t("balance.snapshot.detailed.removeTitle")}
|
|
||||||
aria-label={t("balance.snapshot.detailed.removeTitle")}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,9 @@
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("../../services/db", () => {
|
vi.mock("../../services/db", () => ({
|
||||||
const getDb = vi.fn();
|
getDb: vi.fn(),
|
||||||
return {
|
}));
|
||||||
getDb,
|
|
||||||
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { getDb } from "../../services/db";
|
import { getDb } from "../../services/db";
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,7 +30,7 @@ beforeEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("STARTER_ACCOUNTS", () => {
|
describe("STARTER_ACCOUNTS", () => {
|
||||||
it("ships exactly 4 starters keyed cash/tfsa/rrsp/other", () => {
|
it("ships exactly 4 starters mapping cash/tfsa/rrsp/other", () => {
|
||||||
expect(STARTER_ACCOUNTS).toHaveLength(4);
|
expect(STARTER_ACCOUNTS).toHaveLength(4);
|
||||||
expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([
|
expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([
|
||||||
"cash",
|
"cash",
|
||||||
|
|
@ -43,24 +39,10 @@ describe("STARTER_ACCOUNTS", () => {
|
||||||
"other",
|
"other",
|
||||||
]);
|
]);
|
||||||
for (const s of STARTER_ACCOUNTS) {
|
for (const s of STARTER_ACCOUNTS) {
|
||||||
|
expect(s.categoryKey).toBe(s.key);
|
||||||
expect(s.i18nKey).toMatch(/^balance\.starters\.items\./);
|
expect(s.i18nKey).toMatch(/^balance\.starters\.items\./);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps CELI/REER to the `other` asset class with a vehicle_type (#202)", () => {
|
|
||||||
// After the envelope/asset-class split, only `cash` keeps its own class;
|
|
||||||
// CELI/REER/non-registered all live under `other`, and the envelope is
|
|
||||||
// carried by vehicle_type (NOT an automobile type).
|
|
||||||
const byKey = Object.fromEntries(STARTER_ACCOUNTS.map((s) => [s.key, s]));
|
|
||||||
expect(byKey.cash.categoryKey).toBe("cash");
|
|
||||||
expect(byKey.cash.vehicleType).toBeNull();
|
|
||||||
expect(byKey.tfsa.categoryKey).toBe("other");
|
|
||||||
expect(byKey.tfsa.vehicleType).toBe("tfsa");
|
|
||||||
expect(byKey.rrsp.categoryKey).toBe("other");
|
|
||||||
expect(byKey.rrsp.vehicleType).toBe("rrsp");
|
|
||||||
expect(byKey.other.categoryKey).toBe("other");
|
|
||||||
expect(byKey.other.vehicleType).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getStarterCollisions", () => {
|
describe("getStarterCollisions", () => {
|
||||||
|
|
@ -92,27 +74,6 @@ describe("getStarterCollisions", () => {
|
||||||
expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque"
|
expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque"
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags the CELI starter when a CELI account lives in the `other` class (#202)", async () => {
|
|
||||||
// Post-split, the CELI starter's categoryKey is `other`. An exact-name CELI
|
|
||||||
// account in `other` is a collision; a non-registered account in `other` is not.
|
|
||||||
mockSelect.mockResolvedValueOnce([
|
|
||||||
{ key: "other", account_name: "CELI" },
|
|
||||||
{ key: "other", account_name: "Compte non-enregistré" },
|
|
||||||
]);
|
|
||||||
const result = await getStarterCollisions();
|
|
||||||
expect(result.has("tfsa")).toBe(true);
|
|
||||||
expect(result.has("other")).toBe(true);
|
|
||||||
expect(result.has("rrsp")).toBe(false);
|
|
||||||
expect(result.has("cash")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("queries only the cash + other asset classes (#202)", async () => {
|
|
||||||
mockSelect.mockResolvedValueOnce([]);
|
|
||||||
await getStarterCollisions();
|
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
|
||||||
expect(sql).toMatch(/c\.key IN \('cash','other'\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes archived accounts via SQL filter", async () => {
|
it("excludes archived accounts via SQL filter", async () => {
|
||||||
mockSelect.mockResolvedValueOnce([]);
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
await getStarterCollisions();
|
await getStarterCollisions();
|
||||||
|
|
@ -173,34 +134,6 @@ describe("proposeStarterAccounts", () => {
|
||||||
expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow
|
expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves categories with is_active=1 and writes vehicle_type (#202)", async () => {
|
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
|
|
||||||
mockSelect
|
|
||||||
.mockResolvedValueOnce([{ id: 50 }]) // other category lookup (for tfsa starter)
|
|
||||||
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean
|
|
||||||
mockExecute
|
|
||||||
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 200 }) // INSERT CELI
|
|
||||||
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
|
|
||||||
|
|
||||||
const result = await proposeStarterAccounts(["tfsa"]);
|
|
||||||
expect(result).toEqual([200]);
|
|
||||||
|
|
||||||
// Category lookup must require is_active = 1 so a deactivated ex-envelope
|
|
||||||
// seed is never picked up.
|
|
||||||
const lookupSql = mockSelect.mock.calls[0][0] as string;
|
|
||||||
expect(lookupSql).toMatch(/is_active = 1/);
|
|
||||||
// The CELI starter is told to resolve `other`, not `tfsa`.
|
|
||||||
expect(mockSelect.mock.calls[0][1]).toEqual(["other"]);
|
|
||||||
|
|
||||||
// The INSERT carries vehicle_type='tfsa' as its 3rd param.
|
|
||||||
const insertCall = mockExecute.mock.calls.find((c) =>
|
|
||||||
/INSERT INTO balance_accounts/.test(c[0] as string)
|
|
||||||
)!;
|
|
||||||
const insertSql = insertCall[0] as string;
|
|
||||||
expect(insertSql).toContain("vehicle_type");
|
|
||||||
expect((insertCall[1] as unknown[])[2]).toBe("tfsa");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back on insert failure", async () => {
|
it("rolls back on insert failure", async () => {
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
|
||||||
mockSelect
|
mockSelect
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
// 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
// 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,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
|
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", {
|
const fmt = new Intl.NumberFormat("en-CA", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -128,9 +127,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
grouped[key].push(row);
|
grouped[key].push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Income-statement reading order: revenue first, then expenses, then
|
const typeOrder = ["expense", "income", "transfer"] as const;
|
||||||
// transfers (Issue #278) — matches the compare/trend reports' ordering.
|
|
||||||
const typeOrder = ["income", "expense", "transfer"] as const;
|
|
||||||
const typeLabelKeys: Record<string, string> = {
|
const typeLabelKeys: Record<string, string> = {
|
||||||
expense: "budget.expenses",
|
expense: "budget.expenses",
|
||||||
income: "budget.income",
|
income: "budget.income",
|
||||||
|
|
@ -142,12 +139,19 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
transfer: "budget.totalTransfers",
|
transfer: "budget.totalTransfers",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
|
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
||||||
// computed on the budgeted + previous-year-actual leaves. Mathematically
|
const monthTotals: number[] = Array(12).fill(0);
|
||||||
// equivalent to the old plain grand-total (income − expense + transfer over
|
let annualTotal = 0;
|
||||||
// every leaf), now surfaced as the two labeled result rows below instead of
|
let prevYearTotal = 0;
|
||||||
// a single unlabeled "Total".
|
for (const row of rows) {
|
||||||
const results = computeBudgetResults(rows);
|
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
||||||
|
const sign = signFor(row.category_type);
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
monthTotals[m] += row.months[m] * sign;
|
||||||
|
}
|
||||||
|
annualTotal += row.annual * sign;
|
||||||
|
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||||
|
}
|
||||||
|
|
||||||
const totalCols = 15; // category + prev year + annual + 12 months
|
const totalCols = 15; // category + prev year + annual + 12 months
|
||||||
|
|
||||||
|
|
@ -291,77 +295,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// One type's section: header, its (reorderable) rows, and a leaf-summed
|
|
||||||
// subtotal row. Extracted so it can be called for income/expense, then
|
|
||||||
// again for transfer once the Résultat rows are interleaved between them.
|
|
||||||
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
|
|
||||||
const group = grouped[type];
|
|
||||||
if (!group || group.length === 0) return null;
|
|
||||||
const sign = signFor(type);
|
|
||||||
const leaves = group.filter((r) => !r.is_parent);
|
|
||||||
const sectionMonthTotals: number[] = Array(12).fill(0);
|
|
||||||
let sectionAnnualTotal = 0;
|
|
||||||
let sectionPrevYearTotal = 0;
|
|
||||||
for (const row of leaves) {
|
|
||||||
for (let m = 0; m < 12; m++) {
|
|
||||||
sectionMonthTotals[m] += row.months[m] * sign;
|
|
||||||
}
|
|
||||||
sectionAnnualTotal += row.annual * sign;
|
|
||||||
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Fragment key={type}>
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={totalCols}
|
|
||||||
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
|
|
||||||
>
|
|
||||||
{t(typeLabelKeys[type])}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
|
|
||||||
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
|
||||||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
|
||||||
{t(typeTotalKeys[type])}
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
|
|
||||||
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
|
|
||||||
{sectionMonthTotals.map((total, mIdx) => (
|
|
||||||
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
|
||||||
{formatSigned(total)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A Résultat row (avant-transferts subtotal, or the net bottom line).
|
|
||||||
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
|
|
||||||
// the softer variant mirrors the per-type section-subtotal row above.
|
|
||||||
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
|
|
||||||
const rowClass = strong
|
|
||||||
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
|
|
||||||
: "bg-[var(--muted)]/40 border-b border-[var(--border)]";
|
|
||||||
const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40";
|
|
||||||
const cellWeight = strong ? "" : "font-semibold";
|
|
||||||
const pad = strong ? "py-3" : "py-2.5";
|
|
||||||
return (
|
|
||||||
<tr key={labelKey} className={rowClass}>
|
|
||||||
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
|
|
||||||
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
|
|
||||||
{formatSigned(tot.previousYearTotal)}
|
|
||||||
</td>
|
|
||||||
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
|
|
||||||
{tot.months.map((val, mIdx) => (
|
|
||||||
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
|
|
||||||
{formatSigned(val)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
|
@ -394,16 +327,58 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{renderTypeSection("income")}
|
{typeOrder.map((type) => {
|
||||||
{renderTypeSection("expense")}
|
const group = grouped[type];
|
||||||
{/* Operating result (revenues − expenses), interleaved before the
|
if (!group || group.length === 0) return null;
|
||||||
transfers section only when transfers exist — otherwise it
|
const sign = signFor(type);
|
||||||
equals the net result below and would just be noise. */}
|
const leaves = group.filter((r) => !r.is_parent);
|
||||||
{results.hasTransfers &&
|
const sectionMonthTotals: number[] = Array(12).fill(0);
|
||||||
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
let sectionAnnualTotal = 0;
|
||||||
{renderTypeSection("transfer")}
|
let sectionPrevYearTotal = 0;
|
||||||
{/* Bottom line: result after netting transfers. */}
|
for (const row of leaves) {
|
||||||
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
for (let m = 0; m < 12; m++) {
|
||||||
|
sectionMonthTotals[m] += row.months[m] * sign;
|
||||||
|
}
|
||||||
|
sectionAnnualTotal += row.annual * sign;
|
||||||
|
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Fragment key={type}>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={totalCols}
|
||||||
|
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{t(typeLabelKeys[type])}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
|
||||||
|
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
||||||
|
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
||||||
|
{t(typeTotalKeys[type])}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
|
||||||
|
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
|
||||||
|
{sectionMonthTotals.map((total, mIdx) => (
|
||||||
|
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
||||||
|
{formatSigned(total)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Totals row */}
|
||||||
|
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
||||||
|
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
|
||||||
|
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
|
||||||
|
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
|
||||||
|
{monthTotals.map((total, mIdx) => (
|
||||||
|
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
||||||
|
{formatSigned(total)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
|
||||||
import { computeBudgetResults, sumLeavesForType, signForBudgetType } from "./budgetTableResults";
|
|
||||||
|
|
||||||
// Minimal leaf factory. `months` arrives as the positive-magnitude budgeted
|
|
||||||
// figure BudgetTable itself flips by type via `signFor` — same convention
|
|
||||||
// `sumLeavesForType` re-implements. `annual` always equals `sum(months)`
|
|
||||||
// (useBudget derives it that way; see useBudget.ts's `buildMonths`).
|
|
||||||
function leaf(
|
|
||||||
type: BudgetYearRow["category_type"],
|
|
||||||
months: number[],
|
|
||||||
previousYearTotal = 0,
|
|
||||||
overrides: Partial<BudgetYearRow> = {},
|
|
||||||
): BudgetYearRow {
|
|
||||||
return {
|
|
||||||
category_id: 1,
|
|
||||||
category_name: type,
|
|
||||||
category_color: "#000",
|
|
||||||
category_type: type,
|
|
||||||
parent_id: null,
|
|
||||||
is_parent: false,
|
|
||||||
depth: 0,
|
|
||||||
months,
|
|
||||||
annual: months.reduce((s, v) => s + v, 0),
|
|
||||||
previousYearTotal,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const FLAT12 = (v: number) => Array(12).fill(v) as number[];
|
|
||||||
|
|
||||||
describe("computeBudgetResults — income-statement roll-up (Issue #278)", () => {
|
|
||||||
it("resultBefore = budgeted income − expense; resultNet adds transfers", () => {
|
|
||||||
const r = computeBudgetResults([
|
|
||||||
leaf("income", FLAT12(400)), // annual 4800
|
|
||||||
leaf("expense", FLAT12(300)), // annual 3600, stored positive, flipped to -300/mo
|
|
||||||
leaf("transfer", FLAT12(0)),
|
|
||||||
]);
|
|
||||||
expect(r.resultBefore.months[0]).toBe(100); // 400 − 300
|
|
||||||
expect(r.resultBefore.annual).toBe(1200); // 4800 − 3600
|
|
||||||
expect(r.resultNet.annual).toBe(1200); // balanced (0) transfer → unchanged
|
|
||||||
expect(r.hasTransfers).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
|
|
||||||
const r = computeBudgetResults([
|
|
||||||
leaf("income", FLAT12(1000)),
|
|
||||||
leaf("expense", FLAT12(700)),
|
|
||||||
leaf("transfer", FLAT12(50)), // uncategorized single leg, signed pass-through
|
|
||||||
]);
|
|
||||||
expect(r.resultBefore.annual).toBe(3600); // (1000 − 700) × 12
|
|
||||||
expect(r.resultNet.annual).toBe(4200); // resultBefore + transfer(50×12)
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a deficit yields a negative net result and no transfer line when absent", () => {
|
|
||||||
const r = computeBudgetResults([leaf("income", FLAT12(200)), leaf("expense", FLAT12(250))]);
|
|
||||||
expect(r.resultNet.months[0]).toBe(-50);
|
|
||||||
expect(r.hasTransfers).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
|
||||||
const parent = leaf("expense", FLAT12(999), 0, { is_parent: true });
|
|
||||||
const r = computeBudgetResults([parent, leaf("expense", FLAT12(100))]);
|
|
||||||
expect(r.expense.annual).toBe(-1200); // only the leaf's 100 × 12 (flipped), not 999
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries the previous-year actual through unsigned (already signed in the DB)", () => {
|
|
||||||
// Actual totals arrive pre-signed: expense negative, income positive.
|
|
||||||
const r = computeBudgetResults([
|
|
||||||
leaf("income", FLAT12(100), 1150),
|
|
||||||
leaf("expense", FLAT12(60), -650),
|
|
||||||
]);
|
|
||||||
expect(r.resultBefore.previousYearTotal).toBe(500); // 1150 + (−650), no re-sign
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sumLeavesForType filters by type and excludes parents", () => {
|
|
||||||
const t = sumLeavesForType(
|
|
||||||
[
|
|
||||||
leaf("expense", FLAT12(50)),
|
|
||||||
leaf("expense", FLAT12(999), 0, { is_parent: true }),
|
|
||||||
leaf("income", FLAT12(10)),
|
|
||||||
],
|
|
||||||
"expense",
|
|
||||||
);
|
|
||||||
expect(t.annual).toBe(-600); // 50 × 12, flipped negative
|
|
||||||
expect(t.months[0]).toBe(-50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("signForBudgetType flips expense only", () => {
|
|
||||||
expect(signForBudgetType("expense")).toBe(-1);
|
|
||||||
expect(signForBudgetType("income")).toBe(1);
|
|
||||||
expect(signForBudgetType("transfer")).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
|
||||||
|
|
||||||
export type BudgetSectionType = "income" | "expense" | "transfer";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign multiplier applied to budgeted (months/annual) magnitudes. Budgeted
|
|
||||||
* amounts are entered/stored as positive magnitudes regardless of type and
|
|
||||||
* flipped for display by category type — mirrors `BudgetTable`'s `signFor`.
|
|
||||||
*/
|
|
||||||
export function signForBudgetType(type: BudgetSectionType): 1 | -1 {
|
|
||||||
return type === "expense" ? -1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregate of the budget grid's 3 comparable column groups, already signed:
|
|
||||||
* 12 monthly (budgeted) totals, the annual (budgeted) total, and the
|
|
||||||
* previous-year (actual) total.
|
|
||||||
*/
|
|
||||||
export interface BudgetTotals {
|
|
||||||
/** index 0-11 = Jan-Dec, signed budgeted total */
|
|
||||||
months: number[];
|
|
||||||
/** signed budgeted annual total */
|
|
||||||
annual: number;
|
|
||||||
/** signed actual total from the previous year (already signed in the DB) */
|
|
||||||
previousYearTotal: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function zeroTotals(): BudgetTotals {
|
|
||||||
return { months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sum every non-subtotal (leaf) row of a single type into one `BudgetTotals`.
|
|
||||||
*
|
|
||||||
* The type's sign is applied to the budgeted figures (`months`/`annual`),
|
|
||||||
* matching how `BudgetTable` displays them. `previousYearTotal` is NOT
|
|
||||||
* re-signed: it comes from `getActualTotalsForYear` (a plain signed
|
|
||||||
* `SUM(amount)` over the transactions table), already negative for expenses
|
|
||||||
* and ~0-net for balanced transfers — the same convention `compareResults.ts`
|
|
||||||
* relies on for its own previous-period figures.
|
|
||||||
*/
|
|
||||||
export function sumLeavesForType(rows: BudgetYearRow[], type: BudgetSectionType): BudgetTotals {
|
|
||||||
const sign = signForBudgetType(type);
|
|
||||||
const totals = zeroTotals();
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.is_parent || row.category_type !== type) continue;
|
|
||||||
for (let m = 0; m < 12; m++) totals.months[m] += row.months[m] * sign;
|
|
||||||
totals.annual += row.annual * sign;
|
|
||||||
totals.previousYearTotal += row.previousYearTotal;
|
|
||||||
}
|
|
||||||
return totals;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Component-wise sum of two BudgetTotals (both already signed). */
|
|
||||||
function combine(a: BudgetTotals, b: BudgetTotals): BudgetTotals {
|
|
||||||
return {
|
|
||||||
months: a.months.map((v, i) => v + b.months[i]),
|
|
||||||
annual: a.annual + b.annual,
|
|
||||||
previousYearTotal: a.previousYearTotal + b.previousYearTotal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BudgetResults {
|
|
||||||
income: BudgetTotals;
|
|
||||||
expense: BudgetTotals;
|
|
||||||
transfer: BudgetTotals;
|
|
||||||
/** Budgeted/actual revenues − expenses: the operating result, before transfers. */
|
|
||||||
resultBefore: BudgetTotals;
|
|
||||||
/** resultBefore + transfers: the bottom-line total. */
|
|
||||||
resultNet: BudgetTotals;
|
|
||||||
hasTransfers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Income-statement roll-up for the budget grid (Issue #278), mirroring
|
|
||||||
* `compareResults.ts`'s shape (see that module's doc comment for the sign
|
|
||||||
* convention this relies on).
|
|
||||||
*
|
|
||||||
* Operates on the flat `BudgetYearRow[]` the grid already renders (produced
|
|
||||||
* by `useBudget`) — LEAVES only (`is_parent === false`), so parent subtotal
|
|
||||||
* rows are never double-counted. All three column groups (previous-year
|
|
||||||
* actual, budgeted annual, budgeted monthly) are folded into the same result
|
|
||||||
* rows so "Résultat avant transferts" / "Résultat net" read consistently
|
|
||||||
* across every column of the grid.
|
|
||||||
*/
|
|
||||||
export function computeBudgetResults(rows: BudgetYearRow[]): BudgetResults {
|
|
||||||
const income = sumLeavesForType(rows, "income");
|
|
||||||
const expense = sumLeavesForType(rows, "expense");
|
|
||||||
const transfer = sumLeavesForType(rows, "transfer");
|
|
||||||
const resultBefore = combine(income, expense);
|
|
||||||
const resultNet = combine(resultBefore, transfer);
|
|
||||||
const hasTransfers = rows.some((r) => !r.is_parent && r.category_type === "transfer");
|
|
||||||
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||||
import { comboboxCategoriesForTarget } from "./migrationTargets";
|
|
||||||
import type { Category } from "../../shared/types";
|
|
||||||
import type {
|
import type {
|
||||||
MappingRow as MappingRowType,
|
MappingRow as MappingRowType,
|
||||||
ConfidenceBadge,
|
ConfidenceBadge,
|
||||||
|
|
@ -15,27 +13,13 @@ 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 picks a target via
|
* Called with the new v1 target id + name when the user resolves the row
|
||||||
* the inline type-ahead combobox. Editable on EVERY row (resolved or not),
|
* via the inline dropdown. The dropdown is only rendered for unresolved
|
||||||
* so a user can override an auto-detected target as well as resolve a
|
* ("🟠 needs review") rows — resolved rows just show the target name.
|
||||||
* "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 {
|
||||||
|
|
@ -57,21 +41,14 @@ export default function MappingRow({
|
||||||
onSelect,
|
onSelect,
|
||||||
onResolve,
|
onResolve,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
targetCategories,
|
|
||||||
resolveTarget,
|
|
||||||
}: MappingRowProps) {
|
}: MappingRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { getLeaves } = useCategoryTaxonomy();
|
||||||
|
|
||||||
// The picker lists leaves only. If this row's current target is a non-leaf
|
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
|
||||||
// parent (a low-confidence default like "Divertissement" #1710), it is absent
|
// list flat because the simulate row is narrow; the search box in step 2
|
||||||
// from that list and would blank the combobox input, so re-inject it for this
|
// already helps users find a target by keyword.
|
||||||
// row so it stays visible. It remains clickable, but selecting it is a no-op —
|
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
|
||||||
// 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}`,
|
||||||
|
|
@ -82,18 +59,12 @@ export default function MappingRow({
|
||||||
|
|
||||||
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
||||||
|
|
||||||
// Fired when the user picks a v1 leaf in the type-ahead combobox. The
|
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
// combobox only emits ids that exist in `targetCategories` (never null with
|
const v1TargetId = Number(ev.target.value);
|
||||||
// our config), but we guard defensively. Resolving a "none" row bumps its
|
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
|
||||||
// confidence to "medium" via the reducer; editing an already-resolved row
|
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
|
||||||
// keeps its confidence unchanged (see RESOLVE_ROW).
|
if (!leaf) return;
|
||||||
const handleTargetChange = (v1TargetId: number | null) => {
|
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -148,26 +119,32 @@ export default function MappingRow({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* v1 target — editable type-ahead combobox on EVERY row. Stop click +
|
{/* v1 target (or picker) */}
|
||||||
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 min-w-0"
|
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-[16rem]">
|
{isUnresolved ? (
|
||||||
<CategoryCombobox
|
<select
|
||||||
categories={comboboxCategories}
|
value=""
|
||||||
value={row.v1TargetId ?? null}
|
onChange={handleResolveChange}
|
||||||
onChange={handleTargetChange}
|
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||||
compact
|
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"
|
||||||
placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
|
>
|
||||||
ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
|
<option value="" disabled>
|
||||||
category: row.v2CategoryName,
|
{t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||||
})}
|
</option>
|
||||||
/>
|
{v1Leaves.map((leaf) => (
|
||||||
</div>
|
<option key={leaf.id} value={leaf.id}>
|
||||||
|
{t(leaf.i18n_key, { defaultValue: leaf.name })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-[var(--foreground)]">
|
||||||
|
{targetDisplayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { useMemo, useCallback } from "react";
|
import { useMemo } 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,
|
||||||
|
|
@ -41,24 +36,6 @@ 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;
|
||||||
|
|
@ -172,8 +149,6 @@ export default function StepSimulate({
|
||||||
transactionCount={
|
transactionCount={
|
||||||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||||
}
|
}
|
||||||
targetCategories={targetCategories}
|
|
||||||
resolveTarget={resolveTarget}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
// NetWorthTile — Dashboard KPI-row tile showing the Bilan's latest net worth.
|
|
||||||
//
|
|
||||||
// Issue #279. Deliberately NOT `HubNetBalanceTile` (that one is the reports
|
|
||||||
// hub's transaction-P&L net balance — a different metric). This tile mirrors
|
|
||||||
// `BalanceOverviewCard`'s "latest total" figure and reuses its exact i18n
|
|
||||||
// copy (`balance.overview.latestTotal` / `asOf`) so the label is unambiguous
|
|
||||||
// next to the transactional KPI cards and the "import sources" account
|
|
||||||
// filter — same words as the Bilan page itself, never "balance"/"solde".
|
|
||||||
//
|
|
||||||
// Renders nothing when `data.visible` is false (no balance account yet, or
|
|
||||||
// accounts but no snapshot yet) — never a misleading "$0" tile.
|
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Wallet } from "lucide-react";
|
|
||||||
import type { NetWorthTileData } from "../../services/dashboardService";
|
|
||||||
|
|
||||||
export interface NetWorthTileProps {
|
|
||||||
data: NetWorthTileData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrency(amount: number, language: string): string {
|
|
||||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "CAD",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NetWorthTile({ data }: NetWorthTileProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
if (!data.visible || data.total === null || data.asOfDate === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
|
||||||
const formattedDate = new Date(data.asOfDate).toLocaleDateString(dateLocale, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to="/balance"
|
|
||||||
data-kpi="net-worth"
|
|
||||||
className="flex-1 min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3 hover:border-[var(--primary)] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)] flex items-center gap-1.5">
|
|
||||||
<Wallet size={14} aria-hidden="true" />
|
|
||||||
<span>{t("balance.overview.latestTotal")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
|
|
||||||
{formatCurrency(data.total, i18n.language)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] pt-1 border-t border-[var(--border)]">
|
|
||||||
{t("balance.overview.asOf", { date: formattedDate })}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
import { ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetVsActualRow } from "../../shared/types";
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
|
||||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
|
||||||
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults";
|
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", {
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
|
@ -29,17 +26,6 @@ interface BudgetVsActualTableProps {
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
// Collapse groups keyed by category id; depth mirrors the render logic below
|
|
||||||
// (a missing depth is derived from parent_id) so hidden rows are exactly a
|
|
||||||
// group's indented descendants.
|
|
||||||
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
|
|
||||||
keyOf: (row) => String(row.category_id),
|
|
||||||
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
|
|
||||||
isParent: (row) => row.is_parent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const BVA_EXPANDED_STORAGE_KEY = "reports-bva-expanded";
|
|
||||||
|
|
||||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
|
@ -55,12 +41,6 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
|
|
||||||
const groups = useCollapsibleGroups<BudgetVsActualRow>(
|
|
||||||
BVA_EXPANDED_STORAGE_KEY,
|
|
||||||
BVA_COLLAPSE_ACCESSORS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
|
@ -69,8 +49,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group rows into contiguous type sections (the service already type-sorts:
|
// Group rows by type for section headers
|
||||||
// income → expense → transfer, Issue #277).
|
type SectionType = "expense" | "income" | "transfer";
|
||||||
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
||||||
const typeLabels: Record<SectionType, string> = {
|
const typeLabels: Record<SectionType, string> = {
|
||||||
expense: t("budget.expenses"),
|
expense: t("budget.expenses"),
|
||||||
|
|
@ -92,176 +72,25 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
sections[sections.length - 1].rows.push(row);
|
sections[sections.length - 1].rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Income-statement result lines (Issue #277): income + expense (expense
|
// Grand totals (leaf rows only)
|
||||||
// already arrives signed negative in BudgetVsActualRow — see
|
const leaves = data.filter((r) => !r.is_parent);
|
||||||
// budgetVsActualResults.ts), then the net after adding transfers. Replaces
|
const totals = leaves.reduce(
|
||||||
// the old flat grand total row.
|
(acc, r) => ({
|
||||||
const results = computeResults(data);
|
monthActual: acc.monthActual + r.monthActual,
|
||||||
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
|
monthBudget: acc.monthBudget + r.monthBudget,
|
||||||
const transferSection = sections.find((s) => s.type === "transfer");
|
monthVariation: acc.monthVariation + r.monthVariation,
|
||||||
|
ytdActual: acc.ytdActual + r.ytdActual,
|
||||||
const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
|
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
||||||
const sectionTotals = sumLeaves(section.rows);
|
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
||||||
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
|
}),
|
||||||
const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget);
|
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
||||||
return (
|
);
|
||||||
<Fragment key={section.type}>
|
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
||||||
<tr className="bg-[var(--muted)]">
|
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
||||||
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
|
||||||
{section.label}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
|
||||||
const isParent = row.is_parent;
|
|
||||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
|
||||||
const isTopParent = isParent && depth === 0;
|
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
|
||||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
|
||||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
|
||||||
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
|
||||||
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className={`py-1.5 sticky left-0 z-10 ${
|
|
||||||
isTopParent
|
|
||||||
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
|
||||||
: isIntermediateParent
|
|
||||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
|
||||||
}`}>
|
|
||||||
{isTopParent ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => groups.toggle(row)}
|
|
||||||
aria-expanded={!collapsed}
|
|
||||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
|
||||||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: row.category_color }}
|
|
||||||
/>
|
|
||||||
{row.category_name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: row.category_color }}
|
|
||||||
/>
|
|
||||||
{row.category_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
|
||||||
{cadFormatter(row.monthActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
|
||||||
{cadFormatter(row.monthVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
|
||||||
{pctFormatter(row.monthVariationPct)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
|
||||||
{cadFormatter(row.ytdActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
|
||||||
{cadFormatter(row.ytdVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
|
||||||
{pctFormatter(row.ytdVariationPct)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
|
||||||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(sectionTotals.monthActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
|
||||||
{cadFormatter(sectionTotals.monthVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
|
||||||
{pctFormatter(sectionMonthPct)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(sectionTotals.ytdActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
|
||||||
{cadFormatter(sectionTotals.ytdVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
|
||||||
{pctFormatter(sectionYtdPct)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A result line (before-transfers subtotal or the net total). Every cell is
|
|
||||||
// coloured by its own sign — a surplus is green, a deficit red — mirroring
|
|
||||||
// ComparePeriodTable.renderResultRow (unlike normal rows, which only colour
|
|
||||||
// the variation/pct columns).
|
|
||||||
const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => {
|
|
||||||
const rowBg = strong
|
|
||||||
? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"
|
|
||||||
: "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]";
|
|
||||||
const rowClass = strong
|
|
||||||
? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}`
|
|
||||||
: `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`;
|
|
||||||
const cell = "text-right px-3 py-3";
|
|
||||||
const monthPct = pct(tot.monthVariation, tot.monthBudget);
|
|
||||||
const ytdPct = pct(tot.ytdVariation, tot.ytdBudget);
|
|
||||||
return (
|
|
||||||
<tr key={labelKey} className={rowClass}>
|
|
||||||
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
|
|
||||||
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.monthActual)}`}>
|
|
||||||
{cadFormatter(tot.monthActual)}
|
|
||||||
</td>
|
|
||||||
<td className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
|
|
||||||
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
|
|
||||||
{cadFormatter(tot.monthVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>{pctFormatter(monthPct)}</td>
|
|
||||||
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.ytdActual)}`}>
|
|
||||||
{cadFormatter(tot.ytdActual)}
|
|
||||||
</td>
|
|
||||||
<td className={`${cell} ${variationColor(tot.ytdBudget)}`}>{cadFormatter(tot.ytdBudget)}</td>
|
|
||||||
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{cadFormatter(tot.ytdVariation)}</td>
|
|
||||||
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{pctFormatter(ytdPct)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasGroups = groups.groupCount(data) > 0;
|
|
||||||
const allExpanded = groups.allExpanded(data);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||||
{hasGroups && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
|
||||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleSubtotals}
|
onClick={toggleSubtotals}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
|
@ -312,15 +141,130 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{nonTransferSections.map(renderSection)}
|
{sections.map((section) => {
|
||||||
{/* Operating result (income + expense), shown before the transfers
|
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
|
||||||
section only when transfers exist — otherwise it equals the net
|
const sectionTotals = sectionLeaves.reduce(
|
||||||
total below and would just be noise (mirrors ComparePeriodTable). */}
|
(acc, r) => ({
|
||||||
{results.hasTransfers &&
|
monthActual: acc.monthActual + r.monthActual,
|
||||||
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
monthBudget: acc.monthBudget + r.monthBudget,
|
||||||
{transferSection && renderSection(transferSection)}
|
monthVariation: acc.monthVariation + r.monthVariation,
|
||||||
{/* Bottom line: result after adding transfers. */}
|
ytdActual: acc.ytdActual + r.ytdActual,
|
||||||
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
||||||
|
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
||||||
|
}),
|
||||||
|
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
||||||
|
);
|
||||||
|
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
|
||||||
|
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
|
||||||
|
return (
|
||||||
|
<Fragment key={section.type}>
|
||||||
|
<tr className="bg-[var(--muted)]">
|
||||||
|
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
||||||
|
{section.label}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||||
|
const isParent = row.is_parent;
|
||||||
|
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||||
|
const isTopParent = isParent && depth === 0;
|
||||||
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
|
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||||
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
|
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||||
|
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className={`py-1.5 sticky left-0 z-10 ${
|
||||||
|
isTopParent
|
||||||
|
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||||||
|
: isIntermediateParent
|
||||||
|
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||||
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
|
}`}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: row.category_color }}
|
||||||
|
/>
|
||||||
|
{row.category_name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||||
|
{cadFormatter(row.monthActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||||
|
{cadFormatter(row.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||||
|
{pctFormatter(row.monthVariationPct)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||||
|
{cadFormatter(row.ytdActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||||
|
{cadFormatter(row.ytdVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||||
|
{pctFormatter(row.ytdVariationPct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
||||||
|
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(sectionTotals.monthActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||||
|
{cadFormatter(sectionTotals.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||||
|
{pctFormatter(sectionMonthPct)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(sectionTotals.ytdActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||||
|
{cadFormatter(sectionTotals.ytdVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||||
|
{pctFormatter(sectionYtdPct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Grand totals */}
|
||||||
|
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
||||||
|
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
|
||||||
|
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(totals.monthActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||||
|
{cadFormatter(totals.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||||
|
{pctFormatter(totalMonthPct)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(totals.ytdActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||||
|
{cadFormatter(totals.ytdVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||||
|
{pctFormatter(totalYtdPct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,6 @@ export default function CategoryOverTimeChart({
|
||||||
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
|
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||||
|
|
||||||
// Reads the name-keyed pivot (`data.categories`), which stays top-N-capped
|
|
||||||
// (Issue #264 sidecar) so the stacked series never explode to 80+ layers. The
|
|
||||||
// full, uncapped hierarchy lives in `data.tree` and drives the table instead.
|
|
||||||
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
|
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
|
||||||
const categoryEntries = visibleCategories.map((name, index) => ({
|
const categoryEntries = visibleCategories.map((name, index) => ({
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
import { Fragment } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
import type { CategoryOverTimeData } from "../../shared/types";
|
||||||
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
|
||||||
import { 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);
|
||||||
|
|
@ -20,40 +10,14 @@ function formatMonth(month: string): string {
|
||||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Colours a result figure by sign: surplus green, deficit red, zero neutral. */
|
|
||||||
function resultColor(value: number): string {
|
|
||||||
if (value > 0) return "var(--positive, #10b981)";
|
|
||||||
if (value < 0) return "var(--negative, #ef4444)";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
interface CategoryOverTimeTableProps {
|
||||||
data: CategoryOverTimeData;
|
data: CategoryOverTimeData;
|
||||||
|
hiddenCategories?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
|
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
|
|
||||||
// persisted under a key distinct from the comparable tables. Called before the
|
|
||||||
// early return so the hook order stays stable.
|
|
||||||
const groups = useCollapsibleGroups<OverTimeRow>(
|
|
||||||
OVERTIME_EXPANDED_STORAGE_KEY,
|
|
||||||
OVERTIME_COLLAPSE_ACCESSORS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data.data.length === 0) {
|
if (data.data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
|
@ -62,163 +26,14 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
|
const visibleCategories = hiddenCategories?.size
|
||||||
// the result lines are exact, and homonym categories never collide.
|
? data.categories.filter((name) => !hiddenCategories.has(name))
|
||||||
const analysis = computeOverTimeResults(data);
|
: data.categories;
|
||||||
const { months, hasTransfers, net, beforeTransfers } = analysis;
|
|
||||||
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
|
|
||||||
const colSpan = months.length + 2;
|
|
||||||
|
|
||||||
const hasGroups = groups.groupCount(data.tree) > 0;
|
const months = data.data.map((d) => d.month);
|
||||||
const allExpanded = groups.allExpanded(data.tree);
|
|
||||||
|
|
||||||
// A section = its header, its (visibly) indented hierarchy rows, and a
|
|
||||||
// leaf-summed subtotal row. Only top-level parents collapse (parity with the
|
|
||||||
// comparable tables); the subtotal is always the reducer's leaf sum, so it
|
|
||||||
// never changes when a group is folded away.
|
|
||||||
const renderSection = (section: OverTimeRenderSection) => (
|
|
||||||
<Fragment key={section.type}>
|
|
||||||
{/* Section header */}
|
|
||||||
<tr className="bg-[var(--muted)]">
|
|
||||||
<td
|
|
||||||
colSpan={colSpan}
|
|
||||||
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
|
|
||||||
>
|
|
||||||
{t(SECTION_LABEL_KEY[section.type])}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/* Category rows — neutral leaf/parent magnitudes (not deltas) */}
|
|
||||||
{groups.visible(section.rows).map((row) => {
|
|
||||||
const isParent = row.is_parent;
|
|
||||||
const depth = row.depth;
|
|
||||||
const isTopParent = isParent && depth === 0;
|
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
|
||||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
|
||||||
const paddingClass =
|
|
||||||
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
|
||||||
isTopParent
|
|
||||||
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
|
||||||
: isIntermediateParent
|
|
||||||
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
|
|
||||||
: "hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
className={`py-1.5 sticky left-0 z-10 ${
|
|
||||||
isTopParent
|
|
||||||
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
|
||||||
: isIntermediateParent
|
|
||||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isTopParent ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => groups.toggle(row)}
|
|
||||||
aria-expanded={!collapsed}
|
|
||||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
|
||||||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: row.categoryColor }}
|
|
||||||
/>
|
|
||||||
{row.categoryName}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: row.categoryColor }}
|
|
||||||
/>
|
|
||||||
{row.categoryName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
{months.map((month, monthIdx) => {
|
|
||||||
const value = row.monthly[monthIdx] ?? 0;
|
|
||||||
return (
|
|
||||||
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
|
||||||
{value ? cadFormatter(value) : "—"}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{cadFormatter(row.total)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Section subtotal */}
|
|
||||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
|
|
||||||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
|
|
||||||
{t(SECTION_TOTAL_KEY[section.type])}
|
|
||||||
</td>
|
|
||||||
{months.map((month) => (
|
|
||||||
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
|
|
||||||
{cadFormatter(section.monthly[month])}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{cadFormatter(section.total)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
|
|
||||||
// A result line (before-transfers subtotal or the net bottom line). Amounts are
|
|
||||||
// coloured by sign (surplus green / deficit red). Computed from the raw tree,
|
|
||||||
// so folding groups never moves these figures.
|
|
||||||
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
|
|
||||||
<tr
|
|
||||||
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
|
|
||||||
strong ? "border-t-2 border-[var(--border)]" : "border-b border-[var(--border)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
|
|
||||||
{t(labelKey)}
|
|
||||||
</td>
|
|
||||||
{months.map((month) => (
|
|
||||||
<td
|
|
||||||
key={month}
|
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
|
||||||
style={{ color: resultColor(series.monthly[month]) }}
|
|
||||||
>
|
|
||||||
{cadFormatter(series.monthly[month])}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
|
||||||
style={{ color: resultColor(series.total) }}
|
|
||||||
>
|
|
||||||
{cadFormatter(series.total)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
{hasGroups && (
|
|
||||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
|
||||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
<table className="w-full text-sm whitespace-nowrap">
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
<thead className="sticky top-0 z-20">
|
<thead className="sticky top-0 z-20">
|
||||||
|
|
@ -237,14 +52,57 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{nonTransferSections.map(renderSection)}
|
{visibleCategories.map((category) => {
|
||||||
{/* Operating result (revenues − expenses), interleaved before the
|
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
|
||||||
transfers section only when transfers exist — otherwise it equals
|
return (
|
||||||
the net total below and would just be noise. */}
|
<tr key={category} className="border-b border-[var(--border)]/50">
|
||||||
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
|
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||||
{transferSection && renderSection(transferSection)}
|
<span className="flex items-center gap-2">
|
||||||
{/* Bottom line: result after netting transfers. */}
|
<span
|
||||||
{renderResultRow("reports.compare.resultNet", net, true)}
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: data.colors[category] }}
|
||||||
|
/>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{months.map((month) => {
|
||||||
|
const monthData = data.data.find((d) => d.month === month);
|
||||||
|
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
||||||
|
return (
|
||||||
|
<td key={month} className="text-right px-3 py-1.5">
|
||||||
|
{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,13 +7,9 @@ import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
export interface CompareBudgetViewProps {
|
export interface CompareBudgetViewProps {
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
/** Account (import source) filter — forwarded to getBudgetVsActualData so this
|
|
||||||
* sub-tab respects the same FilterPanel selection as the rest of the Compare
|
|
||||||
* page (Issue #276). Empty/omitted = no filter. */
|
|
||||||
accountIds?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CompareBudgetView({ year, month, accountIds }: CompareBudgetViewProps) {
|
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
|
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -21,7 +17,7 @@ export default function CompareBudgetView({ year, month, accountIds }: CompareBu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
getBudgetVsActualData(year, month, accountIds)
|
getBudgetVsActualData(year, month)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setRows(data);
|
if (!cancelled) setRows(data);
|
||||||
})
|
})
|
||||||
|
|
@ -31,7 +27,7 @@ export default function CompareBudgetView({ year, month, accountIds }: CompareBu
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [year, month, accountIds]);
|
}, [year, month]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,18 @@ export default function ComparePeriodChart({
|
||||||
}: ComparePeriodChartProps) {
|
}: ComparePeriodChartProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
// The chart stays a spending view: the income-statement result (revenues +
|
if (rows.length === 0) {
|
||||||
// result lines, Issue #253) lives in the table, so keep only expense leaves
|
return (
|
||||||
// here — mixing revenue bars into the same axis would misread. Drop subtotal
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||||
// (is_parent) rows too so a group is not double-counted against its own
|
{t("reports.empty.noData")}
|
||||||
// leaves. Sorted by current amount (largest spending first) for a top-down
|
</div>
|
||||||
// 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,
|
||||||
|
|
@ -48,16 +52,6 @@ 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,11 +1,5 @@
|
||||||
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[];
|
||||||
|
|
@ -36,41 +30,24 @@ function formatSignedCurrency(amount: number, language: string): string {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPct(pctValue: number | null, language: string): string {
|
function formatPct(pct: number | null, language: string): string {
|
||||||
if (pctValue === null) return "—";
|
if (pct === 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(pctValue / 100);
|
}).format(pct / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function variationColor(value: number): string {
|
||||||
* Delta colour, direction-aware (Issue #253). Expenses/transfers keep the
|
// Compare report deals with expenses (abs values): spending more is negative
|
||||||
* spending convention (increase → red, decrease → green); income and the
|
// for the user, spending less is positive. Mirror that colour convention
|
||||||
* result lines invert it (higher is better → green). Also used to colour a
|
// consistently so the eye parses the delta sign at a glance.
|
||||||
* result *amount* by sign: a surplus is green, a deficit red.
|
if (value > 0) return "var(--negative, #ef4444)";
|
||||||
*/
|
if (value < 0) return "var(--positive, #10b981)";
|
||||||
function deltaColor(value: number, higherIsBetter: boolean): string {
|
return "";
|
||||||
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,
|
||||||
|
|
@ -79,291 +56,31 @@ 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;
|
||||||
|
|
||||||
// Group rows into contiguous type sections (the service already type-sorts:
|
// Totals across all rows (there is no parent/child structure in compare mode).
|
||||||
// income → expense → transfer).
|
const totals = rows.reduce(
|
||||||
const sectionLabels: Record<SectionType, string> = {
|
(acc, r) => ({
|
||||||
expense: t("reports.compare.sections.expenses"),
|
monthCurrent: acc.monthCurrent + r.currentAmount,
|
||||||
income: t("reports.compare.sections.income"),
|
monthPrevious: acc.monthPrevious + r.previousAmount,
|
||||||
transfer: t("reports.compare.sections.transfers"),
|
monthDelta: acc.monthDelta + r.deltaAbs,
|
||||||
};
|
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
|
||||||
const sectionTotalKeys: Record<SectionType, string> = {
|
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
|
||||||
expense: "reports.compare.totalExpenses",
|
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
|
||||||
income: "reports.compare.totalIncome",
|
}),
|
||||||
transfer: "reports.compare.totalTransfers",
|
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
|
||||||
};
|
);
|
||||||
const sections: { type: SectionType; rows: CategoryDelta[] }[] = [];
|
const totalMonthPct =
|
||||||
let currentType: SectionType | null = null;
|
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null;
|
||||||
for (const row of rows) {
|
const totalYtdPct =
|
||||||
const type = (row.category_type ?? "expense") as SectionType;
|
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null;
|
||||||
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">
|
||||||
|
|
@ -421,25 +138,111 @@ export default function ComparePeriodTable({
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={COL_COUNT} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
<td
|
||||||
|
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{nonTransferSections.map(renderSection)}
|
{rows.map((row) => (
|
||||||
{/* Operating result (revenues − expenses), shown before the
|
<tr
|
||||||
transfers section only when transfers exist — otherwise it
|
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
|
||||||
equals the net total below and would just be noise. */}
|
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
|
||||||
{results.hasTransfers &&
|
>
|
||||||
renderResultRow(
|
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||||
"reports.compare.resultBeforeTransfers",
|
<span className="flex items-center gap-2">
|
||||||
results.resultBefore,
|
<span
|
||||||
false,
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
)}
|
style={{ backgroundColor: row.categoryColor }}
|
||||||
{transferSection && renderSection(transferSection)}
|
/>
|
||||||
{/* Bottom line: result after netting transfers. */}
|
{row.categoryName}
|
||||||
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
</span>
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
// FilterPanel — unit tests (Issue #274).
|
|
||||||
//
|
|
||||||
// NOTE: This project has no @testing-library/react / jsdom configured (see
|
|
||||||
// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
|
|
||||||
// their own test file, has NO internal state or hooks besides `useTranslation`
|
|
||||||
// — mocked below into a plain function — so it can be invoked directly as a
|
|
||||||
// plain function (bypassing JSX/React.createElement's usual `<FilterPanel />`
|
|
||||||
// call site) and its return value (a plain React-element object tree, since
|
|
||||||
// JSX creation never touches the DOM) walked directly. This gives genuine
|
|
||||||
// coverage of the shipped render + selection-wiring code, not a
|
|
||||||
// re-implementation of its logic in the test.
|
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
|
||||||
useTranslation: vi.fn(() => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
Inbox: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import FilterPanel, { toggleAccountId } from "./FilterPanel";
|
|
||||||
import type { FilterPanelProps } from "./FilterPanel";
|
|
||||||
import type { ImportSource } from "../../shared/types";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Minimal React-element tree walker (no renderer/DOM involved)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface ElementNode {
|
|
||||||
type: unknown;
|
|
||||||
props: Record<string, unknown> & { children?: unknown };
|
|
||||||
}
|
|
||||||
|
|
||||||
type FlatNode = ElementNode | string | number;
|
|
||||||
|
|
||||||
function isElementNode(x: unknown): x is ElementNode {
|
|
||||||
return typeof x === "object" && x !== null && "props" in x;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flatten(node: unknown): FlatNode[] {
|
|
||||||
if (Array.isArray(node)) return node.flatMap(flatten);
|
|
||||||
if (node === null || node === undefined || typeof node === "boolean") return [];
|
|
||||||
if (typeof node === "string" || typeof node === "number") return [node];
|
|
||||||
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findCheckboxes(root: unknown): ElementNode[] {
|
|
||||||
return flatten(root).filter(
|
|
||||||
(n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fixtures
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const SOURCE_CHEQUING: ImportSource = {
|
|
||||||
id: 1,
|
|
||||||
name: "Compte chèque Desjardins",
|
|
||||||
date_format: "YYYY-MM-DD",
|
|
||||||
delimiter: ",",
|
|
||||||
encoding: "utf-8",
|
|
||||||
column_mapping: "{}",
|
|
||||||
skip_lines: 0,
|
|
||||||
has_header: true,
|
|
||||||
created_at: "2026-01-01",
|
|
||||||
updated_at: "2026-01-01",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_VISA: ImportSource = {
|
|
||||||
...SOURCE_CHEQUING,
|
|
||||||
id: 2,
|
|
||||||
name: "Visa Desjardins",
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderPanel(overrides: Partial<FilterPanelProps> = {}) {
|
|
||||||
const onAccountIdsChange = vi.fn();
|
|
||||||
const props: FilterPanelProps = {
|
|
||||||
temporalControl: "TEMPORAL_CONTROL_MARKER",
|
|
||||||
accountIds: [],
|
|
||||||
onAccountIdsChange,
|
|
||||||
accounts: [SOURCE_CHEQUING, SOURCE_VISA],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
const element = FilterPanel(props);
|
|
||||||
return { element, onAccountIdsChange };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// toggleAccountId — pure selection logic
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("toggleAccountId", () => {
|
|
||||||
it("adds an absent id to an empty list", () => {
|
|
||||||
expect(toggleAccountId([], 1)).toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends an absent id after the existing ones", () => {
|
|
||||||
expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes a present id, preserving the order of the remaining ids", () => {
|
|
||||||
expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removing the only selected id returns an empty array (= all accounts)", () => {
|
|
||||||
expect(toggleAccountId([1], 1)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mutate the input array", () => {
|
|
||||||
const input = [1, 2];
|
|
||||||
toggleAccountId(input, 3);
|
|
||||||
expect(input).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FilterPanel — render
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("FilterPanel — render", () => {
|
|
||||||
it("renders the injected temporalControl as-is", () => {
|
|
||||||
const { element } = renderPanel();
|
|
||||||
expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders one checkbox per account", () => {
|
|
||||||
const { element } = renderPanel();
|
|
||||||
expect(findCheckboxes(element)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders each account's name as visible text", () => {
|
|
||||||
const { element } = renderPanel();
|
|
||||||
const text = flatten(element);
|
|
||||||
expect(text).toContain("Compte chèque Desjardins");
|
|
||||||
expect(text).toContain("Visa Desjardins");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("checks exactly the accounts present in accountIds", () => {
|
|
||||||
const { element } = renderPanel({ accountIds: [2] });
|
|
||||||
const checkboxes = findCheckboxes(element);
|
|
||||||
expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
|
|
||||||
const { element } = renderPanel({ accountIds: [] });
|
|
||||||
const checkboxes = findCheckboxes(element);
|
|
||||||
expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders zero checkboxes when accounts is empty, without crashing", () => {
|
|
||||||
const { element } = renderPanel({ accounts: [] });
|
|
||||||
expect(findCheckboxes(element)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
|
|
||||||
const { element } = renderPanel();
|
|
||||||
const text = flatten(element);
|
|
||||||
expect(text).toContain("reports.filters.accounts.label");
|
|
||||||
expect(text).toContain("reports.filters.accounts.hint");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FilterPanel — selection wiring
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("FilterPanel — selection wiring", () => {
|
|
||||||
it("checking an unselected account calls onAccountIdsChange with it added", () => {
|
|
||||||
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
|
|
||||||
const checkboxes = findCheckboxes(element);
|
|
||||||
// accounts[1] = SOURCE_VISA (id 2), currently unselected
|
|
||||||
(checkboxes[1].props.onChange as () => void)();
|
|
||||||
expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
|
|
||||||
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
|
|
||||||
const checkboxes = findCheckboxes(element);
|
|
||||||
// accounts[0] = SOURCE_CHEQUING (id 1), currently selected
|
|
||||||
(checkboxes[0].props.onChange as () => void)();
|
|
||||||
expect(onAccountIdsChange).toHaveBeenCalledWith([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("checking a second account preserves the first (multi-select)", () => {
|
|
||||||
const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
|
|
||||||
const checkboxes = findCheckboxes(element);
|
|
||||||
(checkboxes[0].props.onChange as () => void)();
|
|
||||||
(checkboxes[1].props.onChange as () => void)();
|
|
||||||
expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
|
|
||||||
expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
|
|
||||||
//
|
|
||||||
// Design: a "slot" component. Each report page owns its own temporal state
|
|
||||||
// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
|
|
||||||
// via useReportsPeriod, Budget = a plain year) and renders its own control
|
|
||||||
// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
|
|
||||||
// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
|
|
||||||
// enum and does not know which control it is rendering — an earlier design
|
|
||||||
// unifying temporal state behind such an enum was rejected specifically to
|
|
||||||
// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
|
|
||||||
// The only state this component actually owns/shares across report pages is
|
|
||||||
// the account (import source) filter.
|
|
||||||
//
|
|
||||||
// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
|
|
||||||
// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
|
|
||||||
// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
|
|
||||||
// copy in this component deliberately says "sources" everywhere, never
|
|
||||||
// "comptes"/"accounts", so the two concepts never read as the same filter.
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Inbox } from "lucide-react";
|
|
||||||
import type { ImportSource } from "../../shared/types";
|
|
||||||
|
|
||||||
export interface FilterPanelProps {
|
|
||||||
/** The page's own temporal control, rendered as-is. */
|
|
||||||
temporalControl: ReactNode;
|
|
||||||
/** Selected import source ids. Empty array = no filter (all sources). */
|
|
||||||
accountIds: number[];
|
|
||||||
onAccountIdsChange: (accountIds: number[]) => void;
|
|
||||||
/** Import sources available to filter on. */
|
|
||||||
accounts: ImportSource[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles `id` in/out of `accountIds` (add if absent, remove if present),
|
|
||||||
* preserving the relative order of the remaining ids. Pure and exported so
|
|
||||||
* the selection logic is unit-testable without rendering the component.
|
|
||||||
*/
|
|
||||||
export function toggleAccountId(accountIds: number[], id: number): number[] {
|
|
||||||
return accountIds.includes(id)
|
|
||||||
? accountIds.filter((existing) => existing !== id)
|
|
||||||
: [...accountIds, id];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FilterPanel({
|
|
||||||
temporalControl,
|
|
||||||
accountIds,
|
|
||||||
onAccountIdsChange,
|
|
||||||
accounts,
|
|
||||||
}: FilterPanelProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const accountsLabel = t("reports.filters.accounts.label");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4 flex flex-wrap items-start gap-4">
|
|
||||||
<div>{temporalControl}</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
|
|
||||||
<Inbox size={14} />
|
|
||||||
{accountsLabel}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="flex flex-wrap gap-x-4 gap-y-1.5"
|
|
||||||
role="group"
|
|
||||||
aria-label={accountsLabel}
|
|
||||||
>
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<label
|
|
||||||
key={account.id}
|
|
||||||
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={accountIds.includes(account.id)}
|
|
||||||
onChange={() =>
|
|
||||||
onAccountIdsChange(toggleAccountId(accountIds, account.id))
|
|
||||||
}
|
|
||||||
className="accent-[var(--primary)]"
|
|
||||||
/>
|
|
||||||
{account.name}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("reports.filters.accounts.hint")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import type { BudgetVsActualRow } from "../../shared/types";
|
|
||||||
import { computeResults, sumLeaves } from "./budgetVsActualResults";
|
|
||||||
|
|
||||||
// Minimal leaf factory. Amounts arrive from getBudgetVsActualData already in
|
|
||||||
// the per-type sign convention: expense actual/budget are negative (raw,
|
|
||||||
// unsigned SUM(transactions.amount) — expenses are stored negative — and the
|
|
||||||
// user-entered budget negated to match via signFor); income/transfer stay
|
|
||||||
// however they naturally sum (positive for income, signed net for transfer).
|
|
||||||
function leaf(
|
|
||||||
type: "expense" | "income" | "transfer",
|
|
||||||
monthActual: number,
|
|
||||||
monthBudget: number,
|
|
||||||
ytdActual = monthActual,
|
|
||||||
ytdBudget = monthBudget,
|
|
||||||
): BudgetVsActualRow {
|
|
||||||
return {
|
|
||||||
category_id: 1,
|
|
||||||
category_name: type,
|
|
||||||
category_color: "#000",
|
|
||||||
category_type: type,
|
|
||||||
parent_id: null,
|
|
||||||
is_parent: false,
|
|
||||||
monthActual,
|
|
||||||
monthBudget,
|
|
||||||
monthVariation: monthActual - monthBudget,
|
|
||||||
monthVariationPct: monthBudget !== 0 ? (monthActual - monthBudget) / Math.abs(monthBudget) : null,
|
|
||||||
ytdActual,
|
|
||||||
ytdBudget,
|
|
||||||
ytdVariation: ytdActual - ytdBudget,
|
|
||||||
ytdVariationPct: ytdBudget !== 0 ? (ytdActual - ytdBudget) / Math.abs(ytdBudget) : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("computeResults — income-statement roll-up (Issue #277)", () => {
|
|
||||||
it("resultBefore = income + expense (expense already signed negative)", () => {
|
|
||||||
const r = computeResults([
|
|
||||||
leaf("income", 3000, 2800),
|
|
||||||
leaf("expense", -1800, -2000), // spent 1800, budgeted to spend 2000
|
|
||||||
leaf("transfer", 0, 0),
|
|
||||||
]);
|
|
||||||
// Operating result: 3000 + (-1800) = 1200 actual; 2800 + (-2000) = 800 budgeted.
|
|
||||||
expect(r.resultBefore.monthActual).toBe(1200);
|
|
||||||
expect(r.resultBefore.monthBudget).toBe(800);
|
|
||||||
expect(r.resultBefore.monthVariation).toBe(400);
|
|
||||||
// Balanced transfer (0) → net equals the operating result.
|
|
||||||
expect(r.resultNet.monthActual).toBe(1200);
|
|
||||||
expect(r.hasTransfers).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a non-zero transfer moves resultNet away from resultBefore", () => {
|
|
||||||
const r = computeResults([
|
|
||||||
leaf("income", 4000, 4000),
|
|
||||||
leaf("expense", -3000, -3000),
|
|
||||||
leaf("transfer", -200, 0), // a single categorised leg
|
|
||||||
]);
|
|
||||||
expect(r.resultBefore.monthActual).toBe(1000);
|
|
||||||
// Net folds the residual transfer in: 1000 + (−200) = 800.
|
|
||||||
expect(r.resultNet.monthActual).toBe(800);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a deficit yields a negative net result and no transfer line", () => {
|
|
||||||
const r = computeResults([leaf("income", 2000, 2000), leaf("expense", -2500, -2400)]);
|
|
||||||
expect(r.resultNet.monthActual).toBe(-500);
|
|
||||||
expect(r.hasTransfers).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
|
||||||
const parent = { ...leaf("expense", -999, -999), is_parent: true };
|
|
||||||
const r = computeResults([parent, leaf("expense", -100, -80)]);
|
|
||||||
expect(r.expense.monthActual).toBe(-100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries the YTD figures through the same arithmetic", () => {
|
|
||||||
const r = computeResults([
|
|
||||||
leaf("income", 100, 100, 1200, 1150),
|
|
||||||
leaf("expense", -60, -60, -700, -650),
|
|
||||||
]);
|
|
||||||
expect(r.resultBefore.ytdActual).toBe(500);
|
|
||||||
expect(r.resultBefore.ytdBudget).toBe(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sumLeaves adds only leaves", () => {
|
|
||||||
const t = sumLeaves([
|
|
||||||
leaf("expense", -100, -50),
|
|
||||||
{ ...leaf("expense", -999, -999), is_parent: true },
|
|
||||||
]);
|
|
||||||
expect(t.monthActual).toBe(-100);
|
|
||||||
expect(t.monthBudget).toBe(-50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import type { BudgetVsActualRow } from "../../shared/types";
|
|
||||||
|
|
||||||
export type SectionType = "expense" | "income" | "transfer";
|
|
||||||
|
|
||||||
/** Aggregate of the 6 budget-vs-actual figures across a set of leaf rows. */
|
|
||||||
export interface Totals {
|
|
||||||
monthActual: number;
|
|
||||||
monthBudget: number;
|
|
||||||
monthVariation: number;
|
|
||||||
ytdActual: number;
|
|
||||||
ytdBudget: number;
|
|
||||||
ytdVariation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZERO: Totals = {
|
|
||||||
monthActual: 0,
|
|
||||||
monthBudget: 0,
|
|
||||||
monthVariation: 0,
|
|
||||||
ytdActual: 0,
|
|
||||||
ytdBudget: 0,
|
|
||||||
ytdVariation: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Sum every non-subtotal (leaf) row into a single Totals. */
|
|
||||||
export function sumLeaves(rows: BudgetVsActualRow[]): Totals {
|
|
||||||
return rows
|
|
||||||
.filter((r) => !r.is_parent)
|
|
||||||
.reduce<Totals>(
|
|
||||||
(acc, r) => ({
|
|
||||||
monthActual: acc.monthActual + r.monthActual,
|
|
||||||
monthBudget: acc.monthBudget + r.monthBudget,
|
|
||||||
monthVariation: acc.monthVariation + r.monthVariation,
|
|
||||||
ytdActual: acc.ytdActual + r.ytdActual,
|
|
||||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
|
||||||
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
|
||||||
}),
|
|
||||||
{ ...ZERO },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Variation as a fraction of budget (null when budget is 0 — mirrors the
|
|
||||||
* row-level `*VariationPct` fields computed in `getBudgetVsActualData`). */
|
|
||||||
export function pct(variation: number, budget: number): number | null {
|
|
||||||
return budget !== 0 ? variation / Math.abs(budget) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Component-wise a + b across the 6 figures. */
|
|
||||||
function add(a: Totals, b: Totals): Totals {
|
|
||||||
return {
|
|
||||||
monthActual: a.monthActual + b.monthActual,
|
|
||||||
monthBudget: a.monthBudget + b.monthBudget,
|
|
||||||
monthVariation: a.monthVariation + b.monthVariation,
|
|
||||||
ytdActual: a.ytdActual + b.ytdActual,
|
|
||||||
ytdBudget: a.ytdBudget + b.ytdBudget,
|
|
||||||
ytdVariation: a.ytdVariation + b.ytdVariation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BudgetVsActualResults {
|
|
||||||
income: Totals;
|
|
||||||
expense: Totals;
|
|
||||||
transfer: Totals;
|
|
||||||
/** Income + expense: the operating result, before transfers. */
|
|
||||||
resultBefore: Totals;
|
|
||||||
/** resultBefore + transfers: the bottom-line total. */
|
|
||||||
resultNet: Totals;
|
|
||||||
hasTransfers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Income-statement roll-up for the budget-vs-actual report (Issue #277).
|
|
||||||
*
|
|
||||||
* Mirrors `compareResults.computeResults`, with one key sign difference: BVA
|
|
||||||
* amounts already arrive signed the "accounting" way rather than ABS'd to
|
|
||||||
* positive magnitudes. `getBudgetVsActualData` computes `monthActual`/
|
|
||||||
* `ytdActual` as the raw (unsigned-by-us) `SUM(transactions.amount)` — negative
|
|
||||||
* for expense categories, since expense transactions are stored as negative
|
|
||||||
* amounts throughout this app (see reportService/dashboardService `amount < 0`
|
|
||||||
* conventions) — and negates the user-entered (positive) budget figure to
|
|
||||||
* match via `signFor`. So expense totals here are already ≤ 0, and the
|
|
||||||
* operating result is a straight ADD of income + expense (not a subtraction
|
|
||||||
* like the real-vs-real compare, whose expense figures are ABS'd positive
|
|
||||||
* magnitudes). The net just adds the transfer total on top.
|
|
||||||
*/
|
|
||||||
export function computeResults(rows: BudgetVsActualRow[]): BudgetVsActualResults {
|
|
||||||
const byType = (type: SectionType): Totals =>
|
|
||||||
sumLeaves(rows.filter((r) => r.category_type === type));
|
|
||||||
const income = byType("income");
|
|
||||||
const expense = byType("expense");
|
|
||||||
const transfer = byType("transfer");
|
|
||||||
const resultBefore = add(income, expense);
|
|
||||||
const resultNet = add(resultBefore, transfer);
|
|
||||||
const hasTransfers = rows.some((r) => r.category_type === "transfer" && !r.is_parent);
|
|
||||||
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
|
||||||
import { visibleRows } from "../../utils/collapsibleRows";
|
|
||||||
import { computeOverTimeResults } from "./overTimeResults";
|
|
||||||
import {
|
|
||||||
OVERTIME_COLLAPSE_ACCESSORS,
|
|
||||||
OVERTIME_EXPANDED_STORAGE_KEY,
|
|
||||||
groupOverTimeSections,
|
|
||||||
} from "./overTimeTableModel";
|
|
||||||
|
|
||||||
type RowType = "income" | "expense" | "transfer";
|
|
||||||
|
|
||||||
/** Builds one id-keyed tree row; `monthly` is index-aligned to the months list. */
|
|
||||||
function mkRow(
|
|
||||||
categoryId: number,
|
|
||||||
categoryName: string,
|
|
||||||
monthly: number[],
|
|
||||||
category_type: RowType,
|
|
||||||
extra: Partial<OverTimeRow> = {},
|
|
||||||
): OverTimeRow {
|
|
||||||
return {
|
|
||||||
categoryId,
|
|
||||||
categoryName,
|
|
||||||
categoryColor: "#000",
|
|
||||||
monthly,
|
|
||||||
total: monthly.reduce((s, v) => s + v, 0),
|
|
||||||
parent_id: null,
|
|
||||||
is_parent: false,
|
|
||||||
depth: 0,
|
|
||||||
category_type,
|
|
||||||
...extra,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
|
|
||||||
return {
|
|
||||||
categories: [],
|
|
||||||
data: months.map((month) => ({ month })),
|
|
||||||
colors: {},
|
|
||||||
categoryIds: {},
|
|
||||||
types: {},
|
|
||||||
tree,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const MONTHS = ["2025-01", "2025-02"];
|
|
||||||
|
|
||||||
// A realistic parent-first depth-first tree (income → expense → transfer):
|
|
||||||
// Revenus (parent, id 1)
|
|
||||||
// Paie (leaf, id 11)
|
|
||||||
// Bonus (leaf, id 12)
|
|
||||||
// Dépenses (parent, id 2)
|
|
||||||
// Épicerie (leaf, id 21)
|
|
||||||
// Resto (leaf, id 22)
|
|
||||||
// Loyer (top-level leaf, id 23)
|
|
||||||
// Épargne (transfer leaf, id 3)
|
|
||||||
function buildTree(): OverTimeRow[] {
|
|
||||||
return [
|
|
||||||
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
|
|
||||||
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
|
|
||||||
mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
|
|
||||||
mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
|
|
||||||
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }),
|
|
||||||
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }),
|
|
||||||
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
|
|
||||||
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const acc = OVERTIME_COLLAPSE_ACCESSORS;
|
|
||||||
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
|
|
||||||
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
|
|
||||||
|
|
||||||
describe("overTimeTableModel — storage key", () => {
|
|
||||||
it("uses a trends-specific key, distinct from the comparable tables", () => {
|
|
||||||
expect(OVERTIME_EXPANDED_STORAGE_KEY).toBe("reports-trends-expanded");
|
|
||||||
expect(OVERTIME_EXPANDED_STORAGE_KEY).not.toBe("reports-compare-expanded");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overTimeTableModel — collapse accessors", () => {
|
|
||||||
it("keys by category id and reads the snake_case hierarchy block", () => {
|
|
||||||
const parent = buildTree()[0];
|
|
||||||
const leaf = buildTree()[1];
|
|
||||||
expect(acc.keyOf(parent)).toBe("1");
|
|
||||||
expect(acc.depthOf(leaf)).toBe(1);
|
|
||||||
expect(acc.isParent(parent)).toBe(true);
|
|
||||||
expect(acc.isParent(leaf)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overTimeTableModel.groupOverTimeSections", () => {
|
|
||||||
it("splits the tree into ordered sections carrying the FULL hierarchy (parents + leaves)", () => {
|
|
||||||
const data = makeData(MONTHS, buildTree());
|
|
||||||
const analysis = computeOverTimeResults(data);
|
|
||||||
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
|
|
||||||
|
|
||||||
// Income-first, transfer split out for interleaving.
|
|
||||||
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
|
|
||||||
expect(transferSection?.type).toBe("transfer");
|
|
||||||
|
|
||||||
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
|
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
|
||||||
expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
|
|
||||||
expect(income.rows[0].is_parent).toBe(true);
|
|
||||||
|
|
||||||
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
|
||||||
expect(expense.rows.map((r) => r.categoryName)).toEqual([
|
|
||||||
"Dépenses",
|
|
||||||
"Épicerie",
|
|
||||||
"Resto",
|
|
||||||
"Loyer",
|
|
||||||
]);
|
|
||||||
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => {
|
|
||||||
const data = makeData(MONTHS, buildTree());
|
|
||||||
const analysis = computeOverTimeResults(data);
|
|
||||||
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
|
||||||
|
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
|
||||||
// Paie 6000 + Bonus 500 = 6500 (the Revenus parent's own 6500 is NOT added again).
|
|
||||||
expect(income.total).toBe(6500);
|
|
||||||
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
|
|
||||||
|
|
||||||
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
|
||||||
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded).
|
|
||||||
expect(expense.total).toBe(3300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops empty sections: no transfers → transferSection is null", () => {
|
|
||||||
const tree = buildTree().filter((r) => r.category_type !== "transfer");
|
|
||||||
const data = makeData(MONTHS, tree);
|
|
||||||
const analysis = computeOverTimeResults(data);
|
|
||||||
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
|
|
||||||
|
|
||||||
expect(transferSection).toBeNull();
|
|
||||||
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
|
|
||||||
it("is collapsed by default: only depth-0 rows survive an empty expanded set", () => {
|
|
||||||
const data = makeData(MONTHS, buildTree());
|
|
||||||
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
|
|
||||||
|
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
|
||||||
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
|
|
||||||
// Revenus stays (its own subtotal row); Paie/Bonus are folded away.
|
|
||||||
expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]);
|
|
||||||
|
|
||||||
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
|
||||||
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
|
|
||||||
// Dépenses folds its children; the top-level leaf Loyer always stays.
|
|
||||||
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
|
|
||||||
const data = makeData(MONTHS, buildTree());
|
|
||||||
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
|
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
|
||||||
|
|
||||||
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
|
|
||||||
expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
|
|
||||||
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
|
|
||||||
const data = makeData(MONTHS, buildTree());
|
|
||||||
const analysis = computeOverTimeResults(data);
|
|
||||||
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
|
||||||
|
|
||||||
// Figures come from the raw tree (never the visible rows), so they hold in
|
|
||||||
// every collapse state.
|
|
||||||
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income − 3300 expense
|
|
||||||
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
|
|
||||||
expect(income.total).toBe(6500);
|
|
||||||
|
|
||||||
// Folding vs expanding a group only changes how many rows are visible.
|
|
||||||
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
|
|
||||||
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
|
|
||||||
expect(collapsed.length).toBeLessThan(expanded.length);
|
|
||||||
// The subtotal the table shows is identical in both states.
|
|
||||||
expect(income.total).toBe(6500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import type { OverTimeRow } from "../../shared/types";
|
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
|
||||||
import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render-model helpers for the hierarchical trends table (issue #265). Kept in a
|
|
||||||
* pure module — the project has no React render harness, so the grouping and
|
|
||||||
* result-interleaving logic is unit-tested here while `CategoryOverTimeTable`
|
|
||||||
* stays a thin renderer.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collapse groups keyed by category id; depth/parent mirror the tree the render
|
|
||||||
* indents by, so a collapsed group hides exactly its indented descendants. The
|
|
||||||
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
|
|
||||||
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
|
|
||||||
*/
|
|
||||||
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
|
|
||||||
keyOf: (row) => String(row.categoryId),
|
|
||||||
depthOf: (row) => row.depth,
|
|
||||||
isParent: (row) => row.is_parent,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Distinct localStorage key so the trends expansion state never collides with
|
|
||||||
* the comparable tables' `reports-compare-expanded` (issue #265). */
|
|
||||||
export const OVERTIME_EXPANDED_STORAGE_KEY = "reports-trends-expanded";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One rendered section: its type, the FULL hierarchy rows of that type (parents
|
|
||||||
* + leaves, parent-first depth-first — straight off `data.tree`), and the
|
|
||||||
* leaf-summed per-month + total subtotals carried over from the reducer.
|
|
||||||
*
|
|
||||||
* `rows` carries the parents (unlike `OverTimeSection.rows`, which is leaves
|
|
||||||
* only) so the table can render the hierarchy and collapse it; `monthly`/`total`
|
|
||||||
* stay the reducer's leaf sums, so they are unaffected by which rows are folded
|
|
||||||
* away — the "collapse is purely visual" invariant.
|
|
||||||
*/
|
|
||||||
export interface OverTimeRenderSection {
|
|
||||||
type: OverTimeType;
|
|
||||||
rows: OverTimeRow[];
|
|
||||||
/** month (YYYY-MM) -> section subtotal (leaf-summed, collapse-invariant). */
|
|
||||||
monthly: Record<string, number>;
|
|
||||||
/** sum across every month (leaf-summed, collapse-invariant). */
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeOf = (row: OverTimeRow): OverTimeType => {
|
|
||||||
const t = row.category_type;
|
|
||||||
return t === "income" || t === "transfer" ? t : "expense";
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits the id-keyed tree into render sections, driven by the reducer's own
|
|
||||||
* (ordered, non-empty, income → expense → transfer) `analysis.sections` so the
|
|
||||||
* ordering and the subtotals match `computeOverTimeResults` exactly. Each
|
|
||||||
* section is given the FULL hierarchy rows of its type (from `tree`), while its
|
|
||||||
* subtotals stay the reducer's leaf sums.
|
|
||||||
*
|
|
||||||
* Returns the non-transfer sections and the transfer section separately so the
|
|
||||||
* caller can interleave the "result before transfers" line between them
|
|
||||||
* (parity with `ComparePeriodTable`).
|
|
||||||
*/
|
|
||||||
export function groupOverTimeSections(
|
|
||||||
analysis: OverTimeAnalysis,
|
|
||||||
tree: OverTimeRow[],
|
|
||||||
): {
|
|
||||||
nonTransferSections: OverTimeRenderSection[];
|
|
||||||
transferSection: OverTimeRenderSection | null;
|
|
||||||
} {
|
|
||||||
// Full hierarchy rows (parents + leaves) bucketed by type, tree order kept.
|
|
||||||
const rowsByType: Record<OverTimeType, OverTimeRow[]> = {
|
|
||||||
income: [],
|
|
||||||
expense: [],
|
|
||||||
transfer: [],
|
|
||||||
};
|
|
||||||
for (const row of tree) rowsByType[typeOf(row)].push(row);
|
|
||||||
|
|
||||||
const sections: OverTimeRenderSection[] = analysis.sections.map((s) => ({
|
|
||||||
type: s.type,
|
|
||||||
rows: rowsByType[s.type],
|
|
||||||
monthly: s.monthly,
|
|
||||||
total: s.total,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
nonTransferSections: sections.filter((s) => s.type !== "transfer"),
|
|
||||||
transferSection: sections.find((s) => s.type === "transfer") ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -115,11 +115,7 @@ export function useBalanceAccounts() {
|
||||||
try {
|
try {
|
||||||
const [accounts, categories] = await Promise.all([
|
const [accounts, categories] = await Promise.all([
|
||||||
listBalanceAccounts({ includeArchived }),
|
listBalanceAccounts({ includeArchived }),
|
||||||
// Exclude v13-deactivated ex-envelope seeds (tfsa/rrsp) from both the
|
listBalanceCategories(),
|
||||||
// account-form category dropdown AND the category-management list
|
|
||||||
// (Issue #203). Existing accounts were re-linked to `other` by v13, so
|
|
||||||
// nothing points at a deactivated seed — filtering is safe.
|
|
||||||
listBalanceCategories({ includeInactive: false }),
|
|
||||||
]);
|
]);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
|
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,53 @@
|
||||||
// useBalanceOverview — scoped useReducer hook backing BalancePage.
|
// useBalanceOverview — scoped useReducer hook backing BalancePage.
|
||||||
//
|
//
|
||||||
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141, extended in #204):
|
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141):
|
||||||
// - Time-series for the evolution chart (totals + per-category + per-vehicle)
|
// - Time-series for the evolution chart (totals + per-category breakdown)
|
||||||
// - Per-account latest snapshot value + period-anchor value (for Δ%)
|
// - Per-account latest snapshot value + period-anchor value (for Δ%)
|
||||||
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
||||||
// - Chart mode toggle (line / stacked-area)
|
// - Chart mode toggle (line / stacked-area)
|
||||||
// - Group-axis toggle for the stacked mode (asset class / fiscal envelope)
|
|
||||||
//
|
//
|
||||||
// `chartMode` (line/stacked) and `groupAxis` (class/vehicle) are two ORTHOGONAL
|
// Returns are intentionally OUT of scope here — they ship in Issue #142
|
||||||
// dimensions: `groupAxis` only changes which breakdown feeds the stacked chart;
|
// (Modified Dietz). The accounts table reserves columns for the return
|
||||||
// it has no effect in line mode. They are kept as independent state to avoid
|
// metrics with TODO comments.
|
||||||
// conflating "how to draw" with "what to group by" (Issue #204).
|
|
||||||
//
|
|
||||||
// Returns (Modified Dietz) are loaded by the accounts table itself, not here.
|
|
||||||
|
|
||||||
import { useReducer, useEffect, useCallback } from "react";
|
import { useReducer, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
getSnapshotTotalsByDate,
|
getSnapshotTotalsByDate,
|
||||||
getSnapshotTotalsByCategoryAndDate,
|
getSnapshotTotalsByCategoryAndDate,
|
||||||
getSnapshotTotalsByVehicleAndDate,
|
|
||||||
getAccountsLatestSnapshot,
|
getAccountsLatestSnapshot,
|
||||||
getAccountsPeriodAnchor,
|
getAccountsPeriodAnchor,
|
||||||
getAccountLatentGainByLine,
|
|
||||||
rollupLatentGain,
|
|
||||||
type SnapshotTotalPoint,
|
type SnapshotTotalPoint,
|
||||||
type SnapshotCategoryBreakdownPoint,
|
type SnapshotCategoryBreakdownPoint,
|
||||||
type SnapshotVehicleBreakdownPoint,
|
|
||||||
type AccountLatestSnapshot,
|
type AccountLatestSnapshot,
|
||||||
type AccountPeriodAnchor,
|
type AccountPeriodAnchor,
|
||||||
type AccountUnrealizedGain,
|
|
||||||
type LatentGainRollup,
|
|
||||||
type SnapshotDateRange,
|
type SnapshotDateRange,
|
||||||
} from "../services/balance.service";
|
} from "../services/balance.service";
|
||||||
|
|
||||||
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
|
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
|
||||||
export type BalanceChartMode = "line" | "stacked";
|
export type BalanceChartMode = "line" | "stacked";
|
||||||
/** Stacked-chart grouping axis: by asset class (category) or fiscal envelope. */
|
|
||||||
export type BalanceGroupAxis = "class" | "vehicle";
|
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
period: BalancePeriod;
|
period: BalancePeriod;
|
||||||
chartMode: BalanceChartMode;
|
chartMode: BalanceChartMode;
|
||||||
/** Orthogonal to `chartMode`; only meaningful in stacked mode. Default 'class'. */
|
|
||||||
groupAxis: BalanceGroupAxis;
|
|
||||||
evolutionTotals: SnapshotTotalPoint[];
|
evolutionTotals: SnapshotTotalPoint[];
|
||||||
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
|
||||||
accountsLatest: AccountLatestSnapshot[];
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
accountsPeriodAnchor: AccountPeriodAnchor[];
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||||
/**
|
|
||||||
* Per-account unrealized (latent) gain keyed by `account_id`, computed from
|
|
||||||
* each detailed account's LATEST snapshot-line holdings (Issue #216). Only
|
|
||||||
* detailed accounts with holdings appear; simple accounts are absent.
|
|
||||||
*/
|
|
||||||
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
|
||||||
/** Latent gain rolled up by asset class, by envelope, and grand total (#216). */
|
|
||||||
latentGainRollup: LatentGainRollup;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Empty rollup — initial state and the no-detailed-account case. */
|
|
||||||
const EMPTY_ROLLUP: LatentGainRollup = {
|
|
||||||
byClass: [],
|
|
||||||
byVehicle: [],
|
|
||||||
grandTotal: {
|
|
||||||
total_value: 0,
|
|
||||||
total_book_cost: 0,
|
|
||||||
total_gain: 0,
|
|
||||||
total_gain_pct: null,
|
|
||||||
has_unknown_book_cost: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
||||||
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
||||||
| { type: "SET_GROUP_AXIS"; payload: BalanceGroupAxis }
|
|
||||||
| { type: "LOAD_START" }
|
| { type: "LOAD_START" }
|
||||||
| {
|
| {
|
||||||
type: "LOAD_SUCCESS";
|
type: "LOAD_SUCCESS";
|
||||||
payload: {
|
payload: {
|
||||||
evolutionTotals: SnapshotTotalPoint[];
|
evolutionTotals: SnapshotTotalPoint[];
|
||||||
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
|
||||||
accountsLatest: AccountLatestSnapshot[];
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
accountsPeriodAnchor: AccountPeriodAnchor[];
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||||
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
|
||||||
latentGainRollup: LatentGainRollup;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "LOAD_ERROR"; payload: string };
|
| { type: "LOAD_ERROR"; payload: string };
|
||||||
|
|
@ -96,14 +56,10 @@ function initialState(): State {
|
||||||
return {
|
return {
|
||||||
period: "1A",
|
period: "1A",
|
||||||
chartMode: "line",
|
chartMode: "line",
|
||||||
groupAxis: "class",
|
|
||||||
evolutionTotals: [],
|
evolutionTotals: [],
|
||||||
evolutionByCategory: [],
|
evolutionByCategory: [],
|
||||||
evolutionByVehicle: [],
|
|
||||||
accountsLatest: [],
|
accountsLatest: [],
|
||||||
accountsPeriodAnchor: [],
|
accountsPeriodAnchor: [],
|
||||||
latentGainByAccount: {},
|
|
||||||
latentGainRollup: EMPTY_ROLLUP,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -115,8 +71,6 @@ function reducer(state: State, action: Action): State {
|
||||||
return { ...state, period: action.payload };
|
return { ...state, period: action.payload };
|
||||||
case "SET_CHART_MODE":
|
case "SET_CHART_MODE":
|
||||||
return { ...state, chartMode: action.payload };
|
return { ...state, chartMode: action.payload };
|
||||||
case "SET_GROUP_AXIS":
|
|
||||||
return { ...state, groupAxis: action.payload };
|
|
||||||
case "LOAD_START":
|
case "LOAD_START":
|
||||||
return { ...state, isLoading: true, error: null };
|
return { ...state, isLoading: true, error: null };
|
||||||
case "LOAD_SUCCESS":
|
case "LOAD_SUCCESS":
|
||||||
|
|
@ -163,7 +117,6 @@ export interface UseBalanceOverviewResult {
|
||||||
state: State;
|
state: State;
|
||||||
setPeriod: (period: BalancePeriod) => void;
|
setPeriod: (period: BalancePeriod) => void;
|
||||||
setChartMode: (mode: BalanceChartMode) => void;
|
setChartMode: (mode: BalanceChartMode) => void;
|
||||||
setGroupAxis: (axis: BalanceGroupAxis) => void;
|
|
||||||
reload: () => Promise<void>;
|
reload: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,60 +127,20 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const range = computeBalanceDateRange(period);
|
const range = computeBalanceDateRange(period);
|
||||||
// Parallel fetches — no inter-dependency between the queries.
|
// Parallel fetches — no inter-dependency between the four queries.
|
||||||
const [totals, byCategory, byVehicle, latest, anchors] =
|
const [totals, byCategory, latest, anchors] = await Promise.all([
|
||||||
await Promise.all([
|
getSnapshotTotalsByDate(range),
|
||||||
getSnapshotTotalsByDate(range),
|
getSnapshotTotalsByCategoryAndDate(range),
|
||||||
getSnapshotTotalsByCategoryAndDate(range),
|
getAccountsLatestSnapshot(),
|
||||||
getSnapshotTotalsByVehicleAndDate(range),
|
getAccountsPeriodAnchor(range),
|
||||||
getAccountsLatestSnapshot(),
|
]);
|
||||||
getAccountsPeriodAnchor(range),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Latent gain (Issue #216): only detailed accounts that actually carry a
|
|
||||||
// latest snapshot line can have holdings. Fetch each in parallel, fold the
|
|
||||||
// holdings through the shared unrealized-gain guard, then roll up by asset
|
|
||||||
// class / envelope. Per-account fetch failures are isolated (the account
|
|
||||||
// simply has no latent-gain figure). Simple accounts are skipped entirely.
|
|
||||||
const detailed = latest.filter(
|
|
||||||
(a) => a.kind === "detailed" && a.latest_snapshot_line_id != null
|
|
||||||
);
|
|
||||||
const latentEntries = await Promise.all(
|
|
||||||
detailed.map(async (a) => {
|
|
||||||
try {
|
|
||||||
const gain = await getAccountLatentGainByLine(
|
|
||||||
a.latest_snapshot_line_id as number
|
|
||||||
);
|
|
||||||
return { account: a, gain };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const latentGainByAccount: Record<number, AccountUnrealizedGain> = {};
|
|
||||||
for (const e of latentEntries) {
|
|
||||||
if (e) latentGainByAccount[e.account.account_id] = e.gain;
|
|
||||||
}
|
|
||||||
const latentGainRollup = rollupLatentGain(
|
|
||||||
latentEntries
|
|
||||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
|
||||||
.map((e) => ({
|
|
||||||
category_key: e.account.category_key,
|
|
||||||
vehicle_type: e.account.vehicle_type,
|
|
||||||
gain: e.gain,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "LOAD_SUCCESS",
|
type: "LOAD_SUCCESS",
|
||||||
payload: {
|
payload: {
|
||||||
evolutionTotals: totals,
|
evolutionTotals: totals,
|
||||||
evolutionByCategory: byCategory,
|
evolutionByCategory: byCategory,
|
||||||
evolutionByVehicle: byVehicle,
|
|
||||||
accountsLatest: latest,
|
accountsLatest: latest,
|
||||||
accountsPeriodAnchor: anchors,
|
accountsPeriodAnchor: anchors,
|
||||||
latentGainByAccount,
|
|
||||||
latentGainRollup,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -249,11 +162,7 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
|
||||||
dispatch({ type: "SET_CHART_MODE", payload: mode });
|
dispatch({ type: "SET_CHART_MODE", payload: mode });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setGroupAxis = useCallback((axis: BalanceGroupAxis) => {
|
|
||||||
dispatch({ type: "SET_GROUP_AXIS", payload: axis });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reload = useCallback(() => load(state.period), [load, state.period]);
|
const reload = useCallback(() => load(state.period), [load, state.period]);
|
||||||
|
|
||||||
return { state, setPeriod, setChartMode, setGroupAxis, reload };
|
return { state, setPeriod, setChartMode, reload };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
|
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getAllActiveCategories,
|
getAllActiveCategories,
|
||||||
getBudgetEntriesForYear,
|
getBudgetEntriesForYear,
|
||||||
|
|
@ -11,14 +11,11 @@ import {
|
||||||
applyTemplate as applyTemplateSvc,
|
applyTemplate as applyTemplateSvc,
|
||||||
deleteTemplate as deleteTemplateSvc,
|
deleteTemplate as deleteTemplateSvc,
|
||||||
} from "../services/budgetService";
|
} from "../services/budgetService";
|
||||||
import { getAllImportSources } from "../services/transactionService";
|
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
|
||||||
|
|
||||||
interface BudgetState {
|
interface BudgetState {
|
||||||
year: number;
|
year: number;
|
||||||
rows: BudgetYearRow[];
|
rows: BudgetYearRow[];
|
||||||
templates: BudgetTemplate[];
|
templates: BudgetTemplate[];
|
||||||
accounts: ImportSource[];
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
@ -29,7 +26,6 @@ type BudgetAction =
|
||||||
| { type: "SET_SAVING"; payload: boolean }
|
| { type: "SET_SAVING"; payload: boolean }
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
|
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
|
||||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
|
||||||
| { type: "SET_YEAR"; payload: number };
|
| { type: "SET_YEAR"; payload: number };
|
||||||
|
|
||||||
function initialState(): BudgetState {
|
function initialState(): BudgetState {
|
||||||
|
|
@ -37,7 +33,6 @@ function initialState(): BudgetState {
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
rows: [],
|
rows: [],
|
||||||
templates: [],
|
templates: [],
|
||||||
accounts: [],
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -59,8 +54,6 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||||
templates: action.payload.templates,
|
templates: action.payload.templates,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
case "SET_ACCOUNTS":
|
|
||||||
return { ...state, accounts: action.payload };
|
|
||||||
case "SET_YEAR":
|
case "SET_YEAR":
|
||||||
return { ...state, year: action.payload };
|
return { ...state, year: action.payload };
|
||||||
default:
|
default:
|
||||||
|
|
@ -68,17 +61,13 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Income-statement reading order: revenue first, then expenses, then
|
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||||
// transfers (Issue #278) — matches the compare/trend reports' ordering
|
|
||||||
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
|
|
||||||
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
|
||||||
|
|
||||||
export function useBudget() {
|
export function useBudget() {
|
||||||
const { accountIds } = useReportsPeriod();
|
|
||||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const refreshData = useCallback(async (year: number, ids: number[]) => {
|
const refreshData = useCallback(async (year: number) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
@ -87,7 +76,7 @@ export function useBudget() {
|
||||||
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
||||||
getAllActiveCategories(),
|
getAllActiveCategories(),
|
||||||
getBudgetEntriesForYear(year),
|
getBudgetEntriesForYear(year),
|
||||||
getActualTotalsForYear(year - 1, ids),
|
getActualTotalsForYear(year - 1),
|
||||||
getAllTemplates(),
|
getAllTemplates(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -375,21 +364,8 @@ export function useBudget() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshData(state.year, accountIds);
|
refreshData(state.year);
|
||||||
}, [state.year, accountIds, refreshData]);
|
}, [state.year, refreshData]);
|
||||||
|
|
||||||
// Load the import-source list once, for the FilterPanel's account checkboxes
|
|
||||||
// (same query as the Trends/Compare source filters — see useTrends.ts).
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const accounts = await getAllImportSources();
|
|
||||||
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigateYear = useCallback((delta: -1 | 1) => {
|
const navigateYear = useCallback((delta: -1 | 1) => {
|
||||||
dispatch({ type: "SET_YEAR", payload: state.year + delta });
|
dispatch({ type: "SET_YEAR", payload: state.year + delta });
|
||||||
|
|
@ -400,7 +376,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
try {
|
try {
|
||||||
await upsertBudgetEntry(categoryId, state.year, month, amount);
|
await upsertBudgetEntry(categoryId, state.year, month, amount);
|
||||||
await refreshData(state.year, accountIds);
|
await refreshData(state.year);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -410,7 +386,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, accountIds, refreshData]
|
[state.year, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const splitEvenly = useCallback(
|
const splitEvenly = useCallback(
|
||||||
|
|
@ -424,7 +400,7 @@ export function useBudget() {
|
||||||
amounts.push(m < remainder ? base + 0.01 : base);
|
amounts.push(m < remainder ? base + 0.01 : base);
|
||||||
}
|
}
|
||||||
await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
|
await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
|
||||||
await refreshData(state.year, accountIds);
|
await refreshData(state.year);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -434,7 +410,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, accountIds, refreshData]
|
[state.year, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveTemplate = useCallback(
|
const saveTemplate = useCallback(
|
||||||
|
|
@ -447,7 +423,7 @@ export function useBudget() {
|
||||||
.filter((r) => !r.is_parent && r.months[0] !== 0)
|
.filter((r) => !r.is_parent && r.months[0] !== 0)
|
||||||
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
|
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
|
||||||
await saveAsTemplateSvc(name, description, entries);
|
await saveAsTemplateSvc(name, description, entries);
|
||||||
await refreshData(state.year, accountIds);
|
await refreshData(state.year);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -457,7 +433,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.rows, state.year, accountIds, refreshData]
|
[state.rows, state.year, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyTemplate = useCallback(
|
const applyTemplate = useCallback(
|
||||||
|
|
@ -465,7 +441,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
try {
|
try {
|
||||||
await applyTemplateSvc(templateId, state.year, month);
|
await applyTemplateSvc(templateId, state.year, month);
|
||||||
await refreshData(state.year, accountIds);
|
await refreshData(state.year);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -475,7 +451,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, accountIds, refreshData]
|
[state.year, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyTemplateAllMonths = useCallback(
|
const applyTemplateAllMonths = useCallback(
|
||||||
|
|
@ -485,7 +461,7 @@ export function useBudget() {
|
||||||
for (let m = 1; m <= 12; m++) {
|
for (let m = 1; m <= 12; m++) {
|
||||||
await applyTemplateSvc(templateId, state.year, m);
|
await applyTemplateSvc(templateId, state.year, m);
|
||||||
}
|
}
|
||||||
await refreshData(state.year, accountIds);
|
await refreshData(state.year);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -495,7 +471,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, accountIds, refreshData]
|
[state.year, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteTemplate = useCallback(
|
const deleteTemplate = useCallback(
|
||||||
|
|
@ -503,7 +479,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
try {
|
try {
|
||||||
await deleteTemplateSvc(templateId);
|
await deleteTemplateSvc(templateId);
|
||||||
await refreshData(state.year, accountIds);
|
await refreshData(state.year);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
@ -513,7 +489,7 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.year, accountIds, refreshData]
|
[state.year, refreshData]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import {
|
|
||||||
type CollapseAccessors,
|
|
||||||
collapsibleKeys,
|
|
||||||
parseStoredExpanded,
|
|
||||||
serializeExpanded,
|
|
||||||
visibleRows,
|
|
||||||
} from "../utils/collapsibleRows";
|
|
||||||
|
|
||||||
export interface CollapsibleGroups<T> {
|
|
||||||
/** Filters a section's rows down to the ones visible under the current state. */
|
|
||||||
visible: (rows: T[]) => T[];
|
|
||||||
/** True when this top-level parent row is collapsed (its children are hidden). */
|
|
||||||
isCollapsed: (row: T) => boolean;
|
|
||||||
/** Flip one group between collapsed and expanded, then persist. */
|
|
||||||
toggle: (row: T) => void;
|
|
||||||
/** Expand every collapsible group found in `rows`, then persist. */
|
|
||||||
expandAll: (rows: T[]) => void;
|
|
||||||
/** Collapse every group (back to the default), then persist. */
|
|
||||||
collapseAll: () => void;
|
|
||||||
/** True when `rows` has at least one group and all of them are expanded. */
|
|
||||||
allExpanded: (rows: T[]) => boolean;
|
|
||||||
/** Number of collapsible top-level groups in `rows`. */
|
|
||||||
groupCount: (rows: T[]) => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-report collapse/expand state for the hierarchical comparable tables
|
|
||||||
* (issue #254). Groups are collapsed by default (issue #260): the persisted
|
|
||||||
* value is the set of *expanded* group keys, so a first-ever visit (empty set)
|
|
||||||
* shows every group collapsed with only its subtotal, while any group the user
|
|
||||||
* expands is remembered.
|
|
||||||
*
|
|
||||||
* Persistence uses localStorage, the same synchronous store the sibling
|
|
||||||
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
|
|
||||||
* per report via a distinct `storageKey`.
|
|
||||||
*/
|
|
||||||
export function useCollapsibleGroups<T>(
|
|
||||||
storageKey: string,
|
|
||||||
acc: CollapseAccessors<T>,
|
|
||||||
): CollapsibleGroups<T> {
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() =>
|
|
||||||
parseStoredExpanded(
|
|
||||||
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const persist = useCallback(
|
|
||||||
(next: Set<string>) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, serializeExpanded(next));
|
|
||||||
} catch {
|
|
||||||
// Best-effort: a write failure just means the next session falls back
|
|
||||||
// to the collapsed default.
|
|
||||||
}
|
|
||||||
setExpanded(next);
|
|
||||||
},
|
|
||||||
[storageKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
|
|
||||||
|
|
||||||
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
|
|
||||||
|
|
||||||
const toggle = useCallback(
|
|
||||||
(row: T) => {
|
|
||||||
const key = acc.keyOf(row);
|
|
||||||
const next = new Set(expanded);
|
|
||||||
if (next.has(key)) next.delete(key);
|
|
||||||
else next.add(key);
|
|
||||||
persist(next);
|
|
||||||
},
|
|
||||||
[expanded, acc, persist],
|
|
||||||
);
|
|
||||||
|
|
||||||
const expandAll = useCallback(
|
|
||||||
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
|
|
||||||
[acc, persist],
|
|
||||||
);
|
|
||||||
|
|
||||||
const collapseAll = useCallback(() => persist(new Set()), [persist]);
|
|
||||||
|
|
||||||
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
|
|
||||||
|
|
||||||
const allExpanded = useCallback(
|
|
||||||
(rows: T[]) => {
|
|
||||||
const keys = collapsibleKeys(rows, acc);
|
|
||||||
return keys.length > 0 && keys.every((k) => expanded.has(k));
|
|
||||||
},
|
|
||||||
[expanded, acc],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
|
||||||
previousMonth,
|
|
||||||
defaultReferencePeriod,
|
|
||||||
comparisonMeta,
|
|
||||||
syncReferenceOnPeriodChange,
|
|
||||||
} from "./useCompare";
|
|
||||||
|
|
||||||
describe("useCompare helpers", () => {
|
describe("useCompare helpers", () => {
|
||||||
describe("previousMonth", () => {
|
describe("previousMonth", () => {
|
||||||
|
|
@ -49,40 +44,4 @@ 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,7 +1,6 @@
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type { CategoryDelta, ImportSource } from "../shared/types";
|
import type { CategoryDelta } from "../shared/types";
|
||||||
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
||||||
import { getAllImportSources } from "../services/transactionService";
|
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
|
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
|
||||||
|
|
||||||
|
|
@ -14,7 +13,6 @@ interface State {
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
rows: CategoryDelta[];
|
rows: CategoryDelta[];
|
||||||
accounts: ImportSource[];
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +23,6 @@ type Action =
|
||||||
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
||||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
|
||||||
| { type: "SET_ERROR"; payload: string };
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -63,32 +60,6 @@ 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",
|
||||||
|
|
@ -96,7 +67,6 @@ const initialState: State = {
|
||||||
year: defaultRef.year,
|
year: defaultRef.year,
|
||||||
month: defaultRef.month,
|
month: defaultRef.month,
|
||||||
rows: [],
|
rows: [],
|
||||||
accounts: [],
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -113,8 +83,6 @@ function reducer(state: State, action: Action): State {
|
||||||
return { ...state, isLoading: action.payload };
|
return { ...state, isLoading: action.payload };
|
||||||
case "SET_ROWS":
|
case "SET_ROWS":
|
||||||
return { ...state, rows: action.payload, isLoading: false, error: null };
|
return { ...state, rows: action.payload, isLoading: false, error: null };
|
||||||
case "SET_ACCOUNTS":
|
|
||||||
return { ...state, accounts: action.payload };
|
|
||||||
case "SET_ERROR":
|
case "SET_ERROR":
|
||||||
return { ...state, error: action.payload, isLoading: false };
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
default:
|
default:
|
||||||
|
|
@ -123,26 +91,20 @@ function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCompare() {
|
export function useCompare() {
|
||||||
const { from, to, accountIds } = useReportsPeriod();
|
const { from, to } = useReportsPeriod();
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetch = useCallback(
|
const fetch = useCallback(
|
||||||
async (
|
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
|
||||||
mode: CompareMode,
|
|
||||||
subMode: CompareSubMode,
|
|
||||||
year: number,
|
|
||||||
month: number,
|
|
||||||
ids: number[],
|
|
||||||
) => {
|
|
||||||
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
||||||
const id = ++fetchIdRef.current;
|
const id = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
try {
|
try {
|
||||||
const rows =
|
const rows =
|
||||||
subMode === "mom"
|
subMode === "mom"
|
||||||
? await getCompareMonthOverMonth(year, month, ids)
|
? await getCompareMonthOverMonth(year, month)
|
||||||
: await getCompareYearOverYear(year, month, ids);
|
: await getCompareYearOverYear(year, month);
|
||||||
if (id !== fetchIdRef.current) return;
|
if (id !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_ROWS", payload: rows });
|
dispatch({ type: "SET_ROWS", payload: rows });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -154,37 +116,18 @@ export function useCompare() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(state.mode, state.subMode, state.year, state.month, accountIds);
|
fetch(state.mode, state.subMode, state.year, state.month);
|
||||||
}, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]);
|
}, [fetch, state.mode, state.subMode, state.year, state.month]);
|
||||||
|
|
||||||
// Load the import-source list once, for the FilterPanel's account checkboxes
|
// When the URL period changes, align the reference month with `to`.
|
||||||
// (same query as the Trends/Transactions source filters — see useTrends.ts).
|
// The explicit dropdown remains the primary selector — this effect only
|
||||||
|
// keeps the two in sync when the user navigates via PeriodSelector.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
const [y, m] = to.split("-").map(Number);
|
||||||
try {
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
||||||
const accounts = await getAllImportSources();
|
if (y !== state.year || m !== state.month) {
|
||||||
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
||||||
} 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,63 +1,29 @@
|
||||||
// useDashboard — scoped useReducer hook backing DashboardPage (the `/` home page).
|
|
||||||
//
|
|
||||||
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
|
|
||||||
// row, top movers and budget adherence are the exact Cartes widgets, sourced
|
|
||||||
// from `getCartesSnapshot` against a (year, month) reference period. The
|
|
||||||
// Dashboard keeps its OWN two temporal axes, neither of which is
|
|
||||||
// `useReportsPeriod` (M1/I4 deliberately left the Dashboard untouched):
|
|
||||||
// - `period` / `customDateFrom` / `customDateTo` — a flexible date range,
|
|
||||||
// feeding the two range-based transactional widgets (top-expenses bar
|
|
||||||
// chart, category-over-time trend).
|
|
||||||
// - `referenceYear` / `referenceMonth` — a single reference month, feeding
|
|
||||||
// the Cartes snapshot (KPIs + deltas, top movers, budget adherence, the
|
|
||||||
// income-vs-expenses overlay chart). Defaults to the previous complete
|
|
||||||
// month, like Cartes/Compare (`defaultReferencePeriod`).
|
|
||||||
//
|
|
||||||
// The account (import-source) filter (`accountIds`) is Dashboard-local state
|
|
||||||
// (not routed through `useReportsPeriod`), but applies to every transactional
|
|
||||||
// widget above — both temporal axes. It does NOT scope the net-worth tile
|
|
||||||
// (see below): `balance_accounts` is a disjoint concept from `import_sources`.
|
|
||||||
//
|
|
||||||
// The net-worth tile is fetched independently, once, on mount — it is not a
|
|
||||||
// transactional figure, has no relationship to `period`/`referenceMonth`, and
|
|
||||||
// must not be scoped by `accountIds`. Keeping it in its own effect makes that
|
|
||||||
// decoupling explicit in the code, not just in the UI copy.
|
|
||||||
|
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type {
|
import type {
|
||||||
DashboardPeriod,
|
DashboardPeriod,
|
||||||
|
DashboardSummary,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
CartesSnapshot,
|
BudgetVsActualRow,
|
||||||
ImportSource,
|
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import {
|
import {
|
||||||
|
getDashboardSummary,
|
||||||
getExpensesByCategory,
|
getExpensesByCategory,
|
||||||
deriveNetWorthTile,
|
|
||||||
type NetWorthTileData,
|
|
||||||
} from "../services/dashboardService";
|
} from "../services/dashboardService";
|
||||||
import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService";
|
import { getCategoryOverTime } from "../services/reportService";
|
||||||
import { getAllImportSources } from "../services/transactionService";
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
import {
|
|
||||||
getSnapshotTotalsByDate,
|
|
||||||
listSnapshots,
|
|
||||||
listBalanceAccounts,
|
|
||||||
} from "../services/balance.service";
|
|
||||||
import { computeDateRange } from "../utils/dateRange";
|
import { computeDateRange } from "../utils/dateRange";
|
||||||
import { defaultReferencePeriod } from "../utils/referencePeriod";
|
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
|
summary: DashboardSummary;
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
cartesSnapshot: CartesSnapshot | null;
|
budgetVsActual: BudgetVsActualRow[];
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
|
budgetYear: number;
|
||||||
|
budgetMonth: number;
|
||||||
customDateFrom: string;
|
customDateFrom: string;
|
||||||
customDateTo: string;
|
customDateTo: string;
|
||||||
referenceYear: number;
|
|
||||||
referenceMonth: number;
|
|
||||||
accountIds: number[];
|
|
||||||
accounts: ImportSource[];
|
|
||||||
netWorth: NetWorthTileData;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -68,46 +34,30 @@ type DashboardAction =
|
||||||
| {
|
| {
|
||||||
type: "SET_DATA";
|
type: "SET_DATA";
|
||||||
payload: {
|
payload: {
|
||||||
|
summary: DashboardSummary;
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
cartesSnapshot: CartesSnapshot;
|
budgetVsActual: BudgetVsActualRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||||
| { type: "SET_ACCOUNT_IDS"; payload: number[] }
|
|
||||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
|
||||||
| { type: "SET_NET_WORTH"; payload: NetWorthTileData };
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
const yearStartStr = `${now.getFullYear()}-01-01`;
|
const yearStartStr = `${now.getFullYear()}-01-01`;
|
||||||
const defaultRef = defaultReferencePeriod();
|
|
||||||
|
|
||||||
const EMPTY_CATEGORY_OVER_TIME: CategoryOverTimeData = {
|
|
||||||
categories: [],
|
|
||||||
data: [],
|
|
||||||
colors: {},
|
|
||||||
categoryIds: {},
|
|
||||||
types: {},
|
|
||||||
tree: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_NET_WORTH: NetWorthTileData = { visible: false, total: null, asOfDate: null };
|
|
||||||
|
|
||||||
const initialState: DashboardState = {
|
const initialState: DashboardState = {
|
||||||
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
categoryBreakdown: [],
|
categoryBreakdown: [],
|
||||||
categoryOverTime: EMPTY_CATEGORY_OVER_TIME,
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
cartesSnapshot: null,
|
budgetVsActual: [],
|
||||||
period: "year",
|
period: "year",
|
||||||
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||||
|
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||||
customDateFrom: yearStartStr,
|
customDateFrom: yearStartStr,
|
||||||
customDateTo: todayStr,
|
customDateTo: todayStr,
|
||||||
referenceYear: defaultRef.year,
|
|
||||||
referenceMonth: defaultRef.month,
|
|
||||||
accountIds: [],
|
|
||||||
accounts: [],
|
|
||||||
netWorth: EMPTY_NET_WORTH,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -121,28 +71,18 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
||||||
case "SET_DATA":
|
case "SET_DATA":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
summary: action.payload.summary,
|
||||||
categoryBreakdown: action.payload.categoryBreakdown,
|
categoryBreakdown: action.payload.categoryBreakdown,
|
||||||
categoryOverTime: action.payload.categoryOverTime,
|
categoryOverTime: action.payload.categoryOverTime,
|
||||||
cartesSnapshot: action.payload.cartesSnapshot,
|
budgetVsActual: action.payload.budgetVsActual,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
case "SET_PERIOD":
|
case "SET_PERIOD":
|
||||||
return { ...state, period: action.payload };
|
return { ...state, period: action.payload };
|
||||||
|
case "SET_BUDGET_MONTH":
|
||||||
|
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||||
case "SET_CUSTOM_DATES":
|
case "SET_CUSTOM_DATES":
|
||||||
return {
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
...state,
|
|
||||||
period: "custom" as DashboardPeriod,
|
|
||||||
customDateFrom: action.payload.dateFrom,
|
|
||||||
customDateTo: action.payload.dateTo,
|
|
||||||
};
|
|
||||||
case "SET_REFERENCE_PERIOD":
|
|
||||||
return { ...state, referenceYear: action.payload.year, referenceMonth: action.payload.month };
|
|
||||||
case "SET_ACCOUNT_IDS":
|
|
||||||
return { ...state, accountIds: action.payload };
|
|
||||||
case "SET_ACCOUNTS":
|
|
||||||
return { ...state, accounts: action.payload };
|
|
||||||
case "SET_NET_WORTH":
|
|
||||||
return { ...state, netWorth: action.payload };
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
@ -156,9 +96,8 @@ export function useDashboard() {
|
||||||
period: DashboardPeriod,
|
period: DashboardPeriod,
|
||||||
customFrom: string | undefined,
|
customFrom: string | undefined,
|
||||||
customTo: string | undefined,
|
customTo: string | undefined,
|
||||||
refYear: number,
|
bYear: number,
|
||||||
refMonth: number,
|
bMonth: number,
|
||||||
accountIds: number[],
|
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
|
@ -166,17 +105,15 @@ export function useDashboard() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([
|
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
||||||
getExpensesByCategory(dateFrom, dateTo, accountIds),
|
getDashboardSummary(dateFrom, dateTo),
|
||||||
// typeFilter "expense" (Issue #279): this chart is titled "expenses
|
getExpensesByCategory(dateFrom, dateTo),
|
||||||
// over time" but previously aggregated every category type — a
|
getCategoryOverTime(dateFrom, dateTo),
|
||||||
// revenue category could silently show up in an "expenses" trend.
|
getBudgetVsActualData(bYear, bMonth),
|
||||||
getCategoryOverTime(dateFrom, dateTo, undefined, accountIds, "expense"),
|
|
||||||
getCartesSnapshot(refYear, refMonth, "month", accountIds),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } });
|
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
@ -187,59 +124,8 @@ export function useDashboard() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(
|
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
||||||
state.period,
|
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
||||||
state.customDateFrom,
|
|
||||||
state.customDateTo,
|
|
||||||
state.referenceYear,
|
|
||||||
state.referenceMonth,
|
|
||||||
state.accountIds,
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
state.period,
|
|
||||||
state.customDateFrom,
|
|
||||||
state.customDateTo,
|
|
||||||
state.referenceYear,
|
|
||||||
state.referenceMonth,
|
|
||||||
state.accountIds,
|
|
||||||
fetchData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// One-time: import sources for the FilterPanel's account checkboxes (same
|
|
||||||
// query as Trends/Compare/Budget — see useTrends.ts).
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const accounts = await getAllImportSources();
|
|
||||||
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// One-time: net-worth tile (Bilan). Independent of `period`/`referenceMonth`
|
|
||||||
// and NOT scoped by `accountIds` — a different "account" concept
|
|
||||||
// (balance_accounts vs import_sources) and a different metric (net worth vs
|
|
||||||
// transaction P&L). A failure here only hides the tile (best-effort); it
|
|
||||||
// never surfaces as a page-wide error for what is a secondary widget.
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const [accounts, snapshots, totals] = await Promise.all([
|
|
||||||
listBalanceAccounts(),
|
|
||||||
listSnapshots(),
|
|
||||||
getSnapshotTotalsByDate(),
|
|
||||||
]);
|
|
||||||
dispatch({
|
|
||||||
type: "SET_NET_WORTH",
|
|
||||||
payload: deriveNetWorthTile(accounts.length, snapshots.length > 0, totals),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
dispatch({ type: "SET_NET_WORTH", payload: EMPTY_NET_WORTH });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
|
|
@ -249,13 +135,9 @@ export function useDashboard() {
|
||||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setReferencePeriod = useCallback((year: number, month: number) => {
|
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setAccountIds = useCallback((accountIds: number[]) => {
|
return { state, setPeriod, setCustomDates, setBudgetMonth };
|
||||||
dispatch({ type: "SET_ACCOUNT_IDS", payload: accountIds });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { state, setPeriod, setCustomDates, setReferencePeriod, setAccountIds };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod";
|
import { resolveReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
describe("resolveReportsPeriod", () => {
|
describe("resolveReportsPeriod", () => {
|
||||||
const fixedToday = new Date("2026-04-14T12:00:00Z");
|
const fixedToday = new Date("2026-04-14T12:00:00Z");
|
||||||
|
|
@ -51,76 +51,3 @@ describe("resolveReportsPeriod", () => {
|
||||||
expect(result.to).toBe("2026-12-31");
|
expect(result.to).toBe("2026-12-31");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseAccountIds", () => {
|
|
||||||
it("defaults to an empty array (no filter) when the sources param is absent", () => {
|
|
||||||
expect(parseAccountIds(null)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to an empty array for an empty string", () => {
|
|
||||||
expect(parseAccountIds("")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a comma-separated list of ids", () => {
|
|
||||||
expect(parseAccountIds("3,7,12")).toEqual([3, 7, 12]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims whitespace around tokens", () => {
|
|
||||||
expect(parseAccountIds(" 3 , 7 ,12")).toEqual([3, 7, 12]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a single id", () => {
|
|
||||||
expect(parseAccountIds("42")).toEqual([42]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops non-numeric tokens but keeps the valid ones (rejects non-numeric)", () => {
|
|
||||||
expect(parseAccountIds("3,abc,7")).toEqual([3, 7]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops decimal tokens (not a finite integer)", () => {
|
|
||||||
expect(parseAccountIds("3,4.5,7")).toEqual([3, 7]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops empty tokens produced by stray/trailing commas", () => {
|
|
||||||
expect(parseAccountIds("3,,7,")).toEqual([3, 7]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops exponential/hex-looking tokens that Number() would otherwise coerce", () => {
|
|
||||||
expect(parseAccountIds("3,1e3,0x10,7")).toEqual([3, 7]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops unsafe-integer tokens (too large to be a reliable id)", () => {
|
|
||||||
expect(parseAccountIds("3,99999999999999999999,7")).toEqual([3, 7]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty array when every token is invalid", () => {
|
|
||||||
expect(parseAccountIds("abc,def")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("serializeAccountIds", () => {
|
|
||||||
it("joins ids into a comma-separated string", () => {
|
|
||||||
expect(serializeAccountIds([3, 7, 12])).toBe("3,7,12");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a single id", () => {
|
|
||||||
expect(serializeAccountIds([42])).toBe("42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for an empty array so the param is removed (no filter)", () => {
|
|
||||||
expect(serializeAccountIds([])).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops invalid entries (e.g. NaN) before serializing", () => {
|
|
||||||
expect(serializeAccountIds([3, NaN, 7])).toBe("3,7");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when every entry is invalid", () => {
|
|
||||||
expect(serializeAccountIds([NaN, Infinity])).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips through parseAccountIds", () => {
|
|
||||||
const ids = [1, 2, 3];
|
|
||||||
expect(parseAccountIds(serializeAccountIds(ids))).toEqual(ids);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -26,43 +26,6 @@ function currentYearRange(today: Date = new Date()): { from: string; to: string
|
||||||
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches a bare (optionally negative) integer token, e.g. "3" or "-3", but
|
|
||||||
// not "3.5", "abc", "" or "1e3" — deliberately stricter than `Number(...)` so
|
|
||||||
// that hex/exponential-notation strings from a hand-edited URL don't sneak
|
|
||||||
// through as valid ids.
|
|
||||||
const INTEGER_TOKEN = /^-?\d+$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure parser for the `sources` query param (comma-separated account/import
|
|
||||||
* source ids), exposed for the same testability reason as
|
|
||||||
* `resolveReportsPeriod`. Invalid tokens (non-numeric, decimal, empty from a
|
|
||||||
* stray comma, or too large to be a safe integer) are dropped individually
|
|
||||||
* rather than invalidating the whole list — a single corrupted token in a
|
|
||||||
* bookmarked/hand-edited URL should not silently discard the rest of an
|
|
||||||
* otherwise valid selection. Missing param or no valid token → `[]`, which
|
|
||||||
* means "no filter" (all accounts).
|
|
||||||
*/
|
|
||||||
export function parseAccountIds(raw: string | null): number[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
return raw
|
|
||||||
.split(",")
|
|
||||||
.map((token) => token.trim())
|
|
||||||
.filter((token) => INTEGER_TOKEN.test(token))
|
|
||||||
.map(Number)
|
|
||||||
.filter((n) => Number.isSafeInteger(n));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inverse of `parseAccountIds`: serializes account ids back to the `sources`
|
|
||||||
* query param value, or `null` when the param should be removed from the URL
|
|
||||||
* entirely (empty/all-invalid selection = no filter — mirrors how `from`/`to`
|
|
||||||
* are omitted for a non-custom period).
|
|
||||||
*/
|
|
||||||
export function serializeAccountIds(accountIds: number[]): string | null {
|
|
||||||
const valid = accountIds.filter((n) => Number.isSafeInteger(n));
|
|
||||||
return valid.length > 0 ? valid.join(",") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure resolver used by the hook and unit tests. Exposed to keep the core
|
* Pure resolver used by the hook and unit tests. Exposed to keep the core
|
||||||
* logic hookless and testable without rendering a router.
|
* logic hookless and testable without rendering a router.
|
||||||
|
|
@ -94,19 +57,15 @@ export interface UseReportsPeriodResult {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
accountIds: number[];
|
|
||||||
setPeriod: (period: DashboardPeriod) => void;
|
setPeriod: (period: DashboardPeriod) => void;
|
||||||
setCustomDates: (from: string, to: string) => void;
|
setCustomDates: (from: string, to: string) => void;
|
||||||
setAccountIds: (accountIds: number[]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads/writes the active reporting period — and, additively, the account
|
* Reads/writes the active reporting period via the URL query string so it is
|
||||||
* (import source) filter — via the URL query string so both are bookmarkable
|
* bookmarkable and shared across the four report sub-routes.
|
||||||
* and shared across the report sub-routes.
|
|
||||||
*
|
*
|
||||||
* Defaults to the current civil year (Jan 1 → Dec 31) with no account filter
|
* Defaults to the current civil year (Jan 1 → Dec 31).
|
||||||
* (`accountIds: []`, meaning all accounts).
|
|
||||||
*/
|
*/
|
||||||
export function useReportsPeriod(): UseReportsPeriodResult {
|
export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -114,15 +73,12 @@ export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
const rawPeriod = searchParams.get("period");
|
const rawPeriod = searchParams.get("period");
|
||||||
const rawFrom = searchParams.get("from");
|
const rawFrom = searchParams.get("from");
|
||||||
const rawTo = searchParams.get("to");
|
const rawTo = searchParams.get("to");
|
||||||
const rawSources = searchParams.get("sources");
|
|
||||||
|
|
||||||
const { from, to, period } = useMemo(
|
const { from, to, period } = useMemo(
|
||||||
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
|
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
|
||||||
[rawPeriod, rawFrom, rawTo],
|
[rawPeriod, rawFrom, rawTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]);
|
|
||||||
|
|
||||||
const setPeriod = useCallback(
|
const setPeriod = useCallback(
|
||||||
(next: DashboardPeriod) => {
|
(next: DashboardPeriod) => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
|
|
@ -159,24 +115,5 @@ export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
[setSearchParams],
|
[setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAccountIds = useCallback(
|
return { from, to, period, setPeriod, setCustomDates };
|
||||||
(nextAccountIds: number[]) => {
|
|
||||||
setSearchParams(
|
|
||||||
(prev) => {
|
|
||||||
const params = new URLSearchParams(prev);
|
|
||||||
const serialized = serializeAccountIds(nextAccountIds);
|
|
||||||
if (serialized === null) {
|
|
||||||
params.delete("sources");
|
|
||||||
} else {
|
|
||||||
params.set("sources", serialized);
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
},
|
|
||||||
{ replace: true },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setSearchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { from, to, period, accountIds, setPeriod, setCustomDates, setAccountIds };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,535 +0,0 @@
|
||||||
// useSnapshotEditor — reducer + pure-helper unit tests (Issue #213).
|
|
||||||
//
|
|
||||||
// The project has no jsdom / @testing-library renderHook harness (see the note
|
|
||||||
// in StarterAccountsModal.test.tsx), so we test the EXTRACTED pure pieces of
|
|
||||||
// the editor's state machine directly: the `reducer`, the LOADED/prefill
|
|
||||||
// hydration mapper `holdingsFromServiceHoldings`, and the save builders
|
|
||||||
// `buildSimpleLines` / `buildDetailedLines`. This covers the holdings actions
|
|
||||||
// (ADD/REMOVE/SET_HOLDING_FIELD), prefill qty-0 exclusion + price drop, and the
|
|
||||||
// dispatch-on-account.kind invariant (a detailed account under a `simple`
|
|
||||||
// category still flows through the holdings path).
|
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
|
|
||||||
// db is imported transitively (hook → balance.service → db → tauri plugins).
|
|
||||||
// Stub it so no module-load path tries a real connection.
|
|
||||||
vi.mock("../services/db", () => {
|
|
||||||
const getDb = vi.fn();
|
|
||||||
return {
|
|
||||||
getDb,
|
|
||||||
connectToProfile: vi.fn(),
|
|
||||||
initializeNewProfileDb: vi.fn(),
|
|
||||||
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import {
|
|
||||||
reducer,
|
|
||||||
initialState,
|
|
||||||
makeEmptyHolding,
|
|
||||||
holdingsFromServiceHoldings,
|
|
||||||
holdingsFromCsvRows,
|
|
||||||
buildSimpleLines,
|
|
||||||
buildDetailedLines,
|
|
||||||
type HoldingDraft,
|
|
||||||
} from "./useSnapshotEditor";
|
|
||||||
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
|
|
||||||
import { BalanceServiceError } from "../services/balance.service";
|
|
||||||
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";
|
|
||||||
|
|
||||||
function holdingRow(
|
|
||||||
over: Partial<BalanceSnapshotHoldingWithSecurity>
|
|
||||||
): BalanceSnapshotHoldingWithSecurity {
|
|
||||||
return {
|
|
||||||
id: 1,
|
|
||||||
snapshot_line_id: 10,
|
|
||||||
security_id: 100,
|
|
||||||
quantity: 5,
|
|
||||||
unit_price: 20,
|
|
||||||
value: 100,
|
|
||||||
book_cost: 80,
|
|
||||||
price_source: "manual",
|
|
||||||
price_fetched_at: null,
|
|
||||||
created_at: "2026-01-01",
|
|
||||||
updated_at: "2026-01-01",
|
|
||||||
security_symbol: "AAPL",
|
|
||||||
security_name: "Apple Inc.",
|
|
||||||
security_asset_type: "stock",
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = initialState("2026-06-06");
|
|
||||||
|
|
||||||
describe("reducer — holdings actions (#213)", () => {
|
|
||||||
it("ADD_HOLDING appends a draft to the account's basket", () => {
|
|
||||||
const h = makeEmptyHolding("stock");
|
|
||||||
const next = reducer(base, {
|
|
||||||
type: "ADD_HOLDING",
|
|
||||||
payload: { accountId: 42, holding: h },
|
|
||||||
});
|
|
||||||
expect(next.holdings[42]).toHaveLength(1);
|
|
||||||
expect(next.holdings[42][0].rowId).toBe(h.rowId);
|
|
||||||
expect(next.isDirty).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ADD_HOLDING keeps other accounts' baskets untouched", () => {
|
|
||||||
const s1 = reducer(base, {
|
|
||||||
type: "ADD_HOLDING",
|
|
||||||
payload: { accountId: 1, holding: makeEmptyHolding() },
|
|
||||||
});
|
|
||||||
const s2 = reducer(s1, {
|
|
||||||
type: "ADD_HOLDING",
|
|
||||||
payload: { accountId: 2, holding: makeEmptyHolding() },
|
|
||||||
});
|
|
||||||
expect(s2.holdings[1]).toHaveLength(1);
|
|
||||||
expect(s2.holdings[2]).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SET_HOLDING_FIELD updates only the targeted row + field", () => {
|
|
||||||
const a = makeEmptyHolding();
|
|
||||||
const b = makeEmptyHolding();
|
|
||||||
let s = reducer(base, {
|
|
||||||
type: "ADD_HOLDING",
|
|
||||||
payload: { accountId: 7, holding: a },
|
|
||||||
});
|
|
||||||
s = reducer(s, { type: "ADD_HOLDING", payload: { accountId: 7, holding: b } });
|
|
||||||
s = reducer(s, {
|
|
||||||
type: "SET_HOLDING_FIELD",
|
|
||||||
payload: { accountId: 7, rowId: b.rowId, field: "quantity", value: "12" },
|
|
||||||
});
|
|
||||||
expect(s.holdings[7].find((h) => h.rowId === a.rowId)!.quantity).toBe("");
|
|
||||||
expect(s.holdings[7].find((h) => h.rowId === b.rowId)!.quantity).toBe("12");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("REMOVE_HOLDING drops the targeted row, preserving the rest", () => {
|
|
||||||
const a = makeEmptyHolding();
|
|
||||||
const b = makeEmptyHolding();
|
|
||||||
let s = reducer(base, {
|
|
||||||
type: "ADD_HOLDING",
|
|
||||||
payload: { accountId: 7, holding: a },
|
|
||||||
});
|
|
||||||
s = reducer(s, { type: "ADD_HOLDING", payload: { accountId: 7, holding: b } });
|
|
||||||
s = reducer(s, {
|
|
||||||
type: "REMOVE_HOLDING",
|
|
||||||
payload: { accountId: 7, rowId: a.rowId },
|
|
||||||
});
|
|
||||||
expect(s.holdings[7]).toHaveLength(1);
|
|
||||||
expect(s.holdings[7][0].rowId).toBe(b.rowId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("makeEmptyHolding gives each row a distinct rowId", () => {
|
|
||||||
expect(makeEmptyHolding().rowId).not.toBe(makeEmptyHolding().rowId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("RESET wipes both values and holdings", () => {
|
|
||||||
let s = reducer(base, {
|
|
||||||
type: "SET_VALUE",
|
|
||||||
payload: { accountId: 1, value: "100" },
|
|
||||||
});
|
|
||||||
s = reducer(s, {
|
|
||||||
type: "ADD_HOLDING",
|
|
||||||
payload: { accountId: 2, holding: makeEmptyHolding() },
|
|
||||||
});
|
|
||||||
s = reducer(s, { type: "RESET" });
|
|
||||||
expect(s.values).toEqual({});
|
|
||||||
expect(s.holdings).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PREFILL merges simple values and detailed baskets", () => {
|
|
||||||
const draft = makeEmptyHolding();
|
|
||||||
const s = reducer(base, {
|
|
||||||
type: "PREFILL",
|
|
||||||
payload: { values: { 1: "500" }, holdings: { 2: [draft] } },
|
|
||||||
});
|
|
||||||
expect(s.values[1]).toBe("500");
|
|
||||||
expect(s.holdings[2][0].rowId).toBe(draft.rowId);
|
|
||||||
expect(s.isDirty).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("holdingsFromServiceHoldings — LOADED vs PREFILL (#213)", () => {
|
|
||||||
it("LOADED (keepPrice) carries qty, price, book_cost and source", () => {
|
|
||||||
const rows = [holdingRow({ quantity: 5, unit_price: 20, book_cost: 80 })];
|
|
||||||
const [d] = holdingsFromServiceHoldings(rows, { keepPrice: true });
|
|
||||||
expect(d.symbol).toBe("AAPL");
|
|
||||||
expect(d.asset_type).toBe("stock");
|
|
||||||
expect(d.quantity).toBe("5");
|
|
||||||
expect(d.unit_price).toBe("20");
|
|
||||||
expect(d.book_cost).toBe("80");
|
|
||||||
expect(d.price_source).toBe("manual");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PREFILL (keepPrice=false) drops price + source, keeps qty + book_cost", () => {
|
|
||||||
const rows = [holdingRow({ quantity: 5, unit_price: 20, book_cost: 80 })];
|
|
||||||
const [d] = holdingsFromServiceHoldings(rows, { keepPrice: false });
|
|
||||||
expect(d.quantity).toBe("5");
|
|
||||||
expect(d.book_cost).toBe("80");
|
|
||||||
// Price is re-entered / re-fetched each snapshot, so it is intentionally blank.
|
|
||||||
expect(d.unit_price).toBe("");
|
|
||||||
expect(d.price_source).toBeNull();
|
|
||||||
expect(d.price_fetched_at).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps a NULL book_cost to an empty string", () => {
|
|
||||||
const [d] = holdingsFromServiceHoldings([holdingRow({ book_cost: null })], {
|
|
||||||
keepPrice: true,
|
|
||||||
});
|
|
||||||
expect(d.book_cost).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the per-security rows the service returns (qty-0 already excluded upstream)", () => {
|
|
||||||
// getHoldingsForLatestSnapshot excludes qty-0 server-side; the mapper is a
|
|
||||||
// faithful 1:1 projection — a sold-then-rebought title arrives as its
|
|
||||||
// latest non-zero holding and is mapped like any other.
|
|
||||||
const rows = [
|
|
||||||
holdingRow({ security_symbol: "AAPL", quantity: 3 }),
|
|
||||||
holdingRow({ security_symbol: "MSFT", quantity: 7, security_id: 101 }),
|
|
||||||
];
|
|
||||||
const drafts = holdingsFromServiceHoldings(rows, { keepPrice: false });
|
|
||||||
expect(drafts.map((d) => d.symbol)).toEqual(["AAPL", "MSFT"]);
|
|
||||||
expect(drafts.map((d) => d.quantity)).toEqual(["3", "7"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildSimpleLines — scalar save path (#213)", () => {
|
|
||||||
it("emits only non-detailed accounts with a non-empty value", () => {
|
|
||||||
const lines = buildSimpleLines(
|
|
||||||
{ 1: "100", 2: " ", 3: "250", 4: "999" },
|
|
||||||
new Set([4]) // account 4 is detailed → must NOT appear here
|
|
||||||
);
|
|
||||||
expect(lines).toEqual([
|
|
||||||
{ account_id: 1, value: 100, account_kind: "simple" },
|
|
||||||
{ account_id: 3, value: 250, account_kind: "simple" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts comma decimals and throws on a non-numeric value", () => {
|
|
||||||
expect(buildSimpleLines({ 1: "12,5" }, new Set())[0].value).toBe(12.5);
|
|
||||||
expect(() => buildSimpleLines({ 1: "abc" }, new Set())).toThrow(
|
|
||||||
BalanceServiceError
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildDetailedLines — holdings save path (#213)", () => {
|
|
||||||
function draft(over: Partial<HoldingDraft>): HoldingDraft {
|
|
||||||
return {
|
|
||||||
...makeEmptyHolding("stock"),
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("builds one line per detailed account, value = rounded-cent SUM of holdings", () => {
|
|
||||||
const holdings = {
|
|
||||||
5: [
|
|
||||||
draft({ symbol: "AAPL", quantity: "2", unit_price: "10.005", book_cost: "15" }),
|
|
||||||
draft({ symbol: "MSFT", quantity: "1", unit_price: "30.10" }),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const [line] = buildDetailedLines(holdings, new Set([5]));
|
|
||||||
expect(line.account_id).toBe(5);
|
|
||||||
expect(line.holdings).toHaveLength(2);
|
|
||||||
// AAPL 2 * 10.005 = 20.01 (rounded), MSFT 1 * 30.10 = 30.10 → 50.11.
|
|
||||||
expect(line.holdings![0].value).toBe(20.01);
|
|
||||||
expect(line.value).toBe(50.11);
|
|
||||||
// The aggregated line carries the holdings field (marks it detailed) and
|
|
||||||
// no scalar qty/price.
|
|
||||||
expect(line.holdings).toBeDefined();
|
|
||||||
expect(line.quantity).toBeUndefined();
|
|
||||||
expect(line.unit_price).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits an EMPTY holdings array for a detailed account with no positions (still detailed)", () => {
|
|
||||||
const [line] = buildDetailedLines({ 5: [] }, new Set([5]));
|
|
||||||
expect(line.holdings).toEqual([]);
|
|
||||||
expect(line.value).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes a detailed account even when its basket key is absent", () => {
|
|
||||||
// Dispatch is on account.kind: account 9 is detailed but the user never
|
|
||||||
// touched it, so `holdings[9]` is undefined — it must still yield a line
|
|
||||||
// (with an empty holdings array) so the save records it as detailed.
|
|
||||||
const [line] = buildDetailedLines({}, new Set([9]));
|
|
||||||
expect(line.account_id).toBe(9);
|
|
||||||
expect(line.holdings).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips a fully-blank row but throws on a partially-filled one", () => {
|
|
||||||
const blank = draft({ symbol: "", quantity: "", unit_price: "" });
|
|
||||||
const okRow = draft({ symbol: "BTC", quantity: "1", unit_price: "100", asset_type: "crypto" });
|
|
||||||
const built = buildDetailedLines({ 5: [blank, okRow] }, new Set([5]));
|
|
||||||
expect(built[0].holdings).toHaveLength(1);
|
|
||||||
expect(built[0].holdings![0].symbol).toBe("BTC");
|
|
||||||
// A symbol with no quantity is a partial row → typed error, no silent drop.
|
|
||||||
const partial = draft({ symbol: "ETH", quantity: "", unit_price: "" });
|
|
||||||
expect(() => buildDetailedLines({ 5: [partial] }, new Set([5]))).toThrow(
|
|
||||||
BalanceServiceError
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries asset_type + currency + book_cost through to the holding input", () => {
|
|
||||||
const [line] = buildDetailedLines(
|
|
||||||
{
|
|
||||||
5: [
|
|
||||||
draft({
|
|
||||||
symbol: "btc",
|
|
||||||
asset_type: "crypto",
|
|
||||||
currency: "CAD",
|
|
||||||
quantity: "0.5",
|
|
||||||
unit_price: "1000",
|
|
||||||
book_cost: "400",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
new Set([5])
|
|
||||||
);
|
|
||||||
const h = line.holdings![0];
|
|
||||||
expect(h.symbol).toBe("btc"); // trimmed only here; service normalizes case
|
|
||||||
expect(h.asset_type).toBe("crypto");
|
|
||||||
expect(h.currency).toBe("CAD");
|
|
||||||
expect(h.book_cost).toBe(400);
|
|
||||||
expect(h.value).toBe(500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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)", () => {
|
|
||||||
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
|
|
||||||
// detailed; nothing about a 'simple' category should pull it into the
|
|
||||||
// scalar (buildSimpleLines) path.
|
|
||||||
const detailedIds = new Set([5]);
|
|
||||||
const values = { 5: "12345" }; // a stray scalar value must be ignored
|
|
||||||
const holdings = {
|
|
||||||
5: [
|
|
||||||
{
|
|
||||||
...makeEmptyHolding("stock"),
|
|
||||||
symbol: "AAPL",
|
|
||||||
quantity: "2",
|
|
||||||
unit_price: "50",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(buildSimpleLines(values, detailedIds)).toEqual([]); // not scalar
|
|
||||||
const [line] = buildDetailedLines(holdings, detailedIds);
|
|
||||||
expect(line.account_id).toBe(5);
|
|
||||||
expect(line.value).toBe(100);
|
|
||||||
expect(line.holdings![0].symbol).toBe("AAPL");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,6 @@
|
||||||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||||
import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types";
|
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
|
||||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||||
import { getAllImportSources } from "../services/transactionService";
|
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
export type TrendsSubView = "global" | "byCategory";
|
export type TrendsSubView = "global" | "byCategory";
|
||||||
|
|
@ -10,7 +9,6 @@ interface State {
|
||||||
subView: TrendsSubView;
|
subView: TrendsSubView;
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
accounts: ImportSource[];
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -20,14 +18,12 @@ type Action =
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
|
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
|
||||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
|
||||||
| { type: "SET_ERROR"; payload: string };
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
subView: "byCategory",
|
subView: "global",
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
accounts: [],
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -42,8 +38,6 @@ function reducer(state: State, action: Action): State {
|
||||||
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
|
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
|
||||||
case "SET_CATEGORY_OVER_TIME":
|
case "SET_CATEGORY_OVER_TIME":
|
||||||
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
|
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
|
||||||
case "SET_ACCOUNTS":
|
|
||||||
return { ...state, accounts: action.payload };
|
|
||||||
case "SET_ERROR":
|
case "SET_ERROR":
|
||||||
return { ...state, error: action.payload, isLoading: false };
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
default:
|
default:
|
||||||
|
|
@ -52,49 +46,33 @@ function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrends() {
|
export function useTrends() {
|
||||||
const { from, to, accountIds } = useReportsPeriod();
|
const { from, to } = useReportsPeriod();
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetch = useCallback(
|
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
|
||||||
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
|
const id = ++fetchIdRef.current;
|
||||||
const id = ++fetchIdRef.current;
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
try {
|
||||||
try {
|
if (subView === "global") {
|
||||||
if (subView === "global") {
|
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||||
const data = await getMonthlyTrends(dateFrom, dateTo, ids);
|
|
||||||
if (id !== fetchIdRef.current) return;
|
|
||||||
dispatch({ type: "SET_TRENDS", payload: data });
|
|
||||||
} else {
|
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
|
|
||||||
if (id !== fetchIdRef.current) return;
|
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (id !== fetchIdRef.current) return;
|
if (id !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_TRENDS", payload: data });
|
||||||
|
} else {
|
||||||
|
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
[]
|
if (id !== fetchIdRef.current) return;
|
||||||
);
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
useEffect(() => {
|
|
||||||
fetch(state.subView, from, to, accountIds);
|
|
||||||
}, [fetch, state.subView, from, to, accountIds]);
|
|
||||||
|
|
||||||
// Load the import-source list once, for the FilterPanel's account checkboxes
|
|
||||||
// (same query as the Transactions single-source filter — see useTransactions.ts).
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const accounts = await getAllImportSources();
|
|
||||||
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(state.subView, from, to);
|
||||||
|
}, [fetch, state.subView, from, to]);
|
||||||
|
|
||||||
const setSubView = useCallback((sv: TrendsSubView) => {
|
const setSubView = useCallback((sv: TrendsSubView) => {
|
||||||
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"kpiSectionTitle": "Key indicators",
|
"balance": "Balance",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
"net": "Net",
|
"net": "Net",
|
||||||
|
|
@ -50,11 +50,10 @@
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to use the Dashboard",
|
"title": "How to use the Dashboard",
|
||||||
"tips": [
|
"tips": [
|
||||||
"The filter bar at the top picks the date range and which import sources to include",
|
"Use the period selector (top right) to view different time ranges",
|
||||||
"The key indicators (income, expenses, net, savings rate) cover the chosen reference month, with their change vs the previous month and vs last year",
|
"Summary cards show your balance, income, and expenses for the selected period",
|
||||||
"The \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source",
|
"The pie chart breaks down your expenses by category",
|
||||||
"Rising/falling categories and budget adherence summarize the reference month's changes",
|
"Recent transactions are listed at the bottom"
|
||||||
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -380,10 +379,6 @@
|
||||||
"budgetVsActual": "Budget vs Actual",
|
"budgetVsActual": "Budget vs Actual",
|
||||||
"subtotalsOnTop": "Subtotals on top",
|
"subtotalsOnTop": "Subtotals on top",
|
||||||
"subtotalsOnBottom": "Subtotals on bottom",
|
"subtotalsOnBottom": "Subtotals on bottom",
|
||||||
"collapse": {
|
|
||||||
"expandAll": "Expand all",
|
|
||||||
"collapseAll": "Collapse all"
|
|
||||||
},
|
|
||||||
"detail": {
|
"detail": {
|
||||||
"showAmounts": "Show amounts",
|
"showAmounts": "Show amounts",
|
||||||
"hideAmounts": "Hide amounts"
|
"hideAmounts": "Hide amounts"
|
||||||
|
|
@ -393,18 +388,15 @@
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"allTypes": "All types",
|
"allTypes": "All types"
|
||||||
"accounts": {
|
|
||||||
"label": "Import sources",
|
|
||||||
"hint": "No selection = all sources"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
"ytd": "Year-to-Date",
|
"ytd": "Year-to-Date",
|
||||||
"dollarVar": "$ Var",
|
"dollarVar": "$ Var",
|
||||||
"pctVar": "% Var",
|
"pctVar": "% Var",
|
||||||
"noData": "No budget or transaction data for this period."
|
"noData": "No budget or transaction data for this period.",
|
||||||
|
"titlePrefix": "Budget vs Actual for"
|
||||||
},
|
},
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
|
|
@ -435,16 +427,7 @@
|
||||||
"referenceMonth": "Reference month",
|
"referenceMonth": "Reference month",
|
||||||
"currentAmount": "Current",
|
"currentAmount": "Current",
|
||||||
"previousAmount": "Previous",
|
"previousAmount": "Previous",
|
||||||
"sections": {
|
"totalRow": "Total"
|
||||||
"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",
|
||||||
|
|
@ -962,36 +945,31 @@
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"title": "Balance Sheet",
|
"title": "Balance Sheet",
|
||||||
"overview": "The Balance Sheet is a net-worth view: you periodically enter a dated snapshot of all your accounts, track their evolution over time, and compute the true return of each investment account by linking transfers (deposits/withdrawals) to the matching accounts. Two independent axes: the asset class (the account's type: Cash, Funds / ETF, Stocks, Crypto, Other) and the fiscal envelope (an optional account attribute: TFSA, RRSP, RRIF, FHSA, RESP, or none). An account is entered either as a single amount (simple account) or security by security (detailed account: each holding with quantity × price and a cost basis, the account value being the sum of its positions).",
|
"overview": "The Balance Sheet is a net-worth view: you periodically enter a dated snapshot of all your accounts (cash, RRSP, TFSA, funds, stocks, crypto, other), track their evolution over time, and compute the true return of each investment account by linking transfers (deposits/withdrawals) to the matching accounts.",
|
||||||
"features": [
|
"features": [
|
||||||
"5 standard types pre-installed, which are asset classes (Cash, Funds / ETF, Stocks, Crypto, Other) — renameable, non-deletable; a type groups accounts of the same nature (distinct from transaction categories). Fiscal envelopes are no longer types",
|
"7 standard categories pre-installed (Cash, TFSA, RRSP, Fund, Stock, Crypto, Other) — renameable, non-deletable",
|
||||||
"Custom type creation with simple (direct amount) or priced (quantity × unit price) entry mode",
|
"Custom category creation with simple (direct amount) or priced (quantity × unit price) kind",
|
||||||
"Detailed accounts (per security): an account can hold several securities — each security has its own row (symbol, quantity, price, value, cost basis, unrealized gain), the account value being the sum of its securities. Security picker with autocomplete and inline creation (symbol + asset class Stock/Crypto)",
|
"Accounts per category: name, optional symbol, currency (CAD at MVP), notes",
|
||||||
"Unrealized gain per security and aggregated (by account, by asset class, by envelope): value − cost basis, in $ and %; per-security drill-down in the accounts table; a position without a cost basis shows \"N/A\" and is excluded from the %",
|
"Dated snapshots with a UNIQUE constraint per date — editing means revisiting the same date, never duplicating",
|
||||||
"\"Detail into securities\" wizard: switches a simple account to detailed from a pivot date; past aggregated history stays frozen and read-only",
|
"\"Prefill from previous snapshot\" button: copies simple values + priced quantities",
|
||||||
"Accounts per type: name, optional fiscal envelope (Non-registered, TFSA, RRSP, RRIF, FHSA, RESP, or none by default), optional symbol (even for priced types — it only drives automatic price fetching), currency (CAD at MVP), notes",
|
|
||||||
"Renaming a type no longer breaks bilingual support: the custom name is stored separately, the original FR/EN translation is preserved",
|
|
||||||
"Dated snapshots with a UNIQUE constraint per date — editing means revisiting the same date, never duplicating; an existing snapshot's date can be moved (lines preserved) when the target date is free",
|
|
||||||
"\"Prefill from previous snapshot\" button: copies simple values, priced quantities, and the securities/quantities/cost bases of detailed accounts (a security at quantity 0 is skipped)",
|
|
||||||
"Linking existing transactions to a balance account (modal with filters and auto-suggested direction)",
|
"Linking existing transactions to a balance account (modal with filters and auto-suggested direction)",
|
||||||
"Attribution icon in the Transactions page for transactions linked to a transfer",
|
"Attribution icon in the Transactions page for transactions linked to a transfer",
|
||||||
"Evolution chart with line or stacked-area mode, with an axis sub-toggle: by asset class (default) or by envelope + vertical markers for tagged transfers (green = in, red = out)",
|
"Evolution chart with line or stacked-area-by-category mode + vertical markers for tagged transfers (green = in, red = out)",
|
||||||
"Accounts table with 3 Modified Dietz return columns (3M / 1Y / since inception) + side-by-side unadjusted return column, collapsed by default (toggle, choice remembered)",
|
"Accounts table with 3 Modified Dietz return columns (3M / 1Y / since inception) + side-by-side unadjusted return column",
|
||||||
"Warning if the latest snapshot is more than 60 days old",
|
"Warning if the latest snapshot is more than 60 days old",
|
||||||
"Soft-delete of accounts (Archive) — hidden from new snapshots, preserved in history",
|
"Soft-delete of accounts (Archive) — hidden from new snapshots, preserved in history",
|
||||||
"Snapshot deletion with double-confirmation by retyping the date",
|
"Snapshot deletion with double-confirmation by retyping the date",
|
||||||
"Privacy-first: everything stays local, no outbound calls at MVP"
|
"Privacy-first: everything stays local, no outbound calls at MVP"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Go to /balance/accounts → Types tab to create an extra asset class if needed, as simple (direct amount) or priced (quantity × unit price)",
|
"Go to /balance/accounts → Categories tab to create an extra category if needed (RRIF as simple, or Stocks Wealthsimple as priced)",
|
||||||
"Go to the Accounts tab to create each account (Tangerine under Cash, BTC Ledger under Crypto with symbol BTC — optional). Pick the fiscal envelope if the account sits in a TFSA/RRSP/etc., or leave it on \"None\"",
|
"Go to the Accounts tab to create each account (TFSA Tangerine under TFSA, BTC Ledger under Crypto with symbol BTC)",
|
||||||
"Click \"+ New snapshot\" from /balance to open /balance/snapshot at today's date",
|
"Click \"+ New snapshot\" from /balance to open /balance/snapshot at today's date",
|
||||||
"Fill in values per account (grouped by type). For priced accounts, enter quantity and unit price — value is computed",
|
"Fill in values per account (grouped by category). For priced accounts, enter quantity and unit price — value is computed",
|
||||||
"Save. The chart on /balance refreshes immediately",
|
"Save. The chart on /balance refreshes immediately",
|
||||||
"To compute the real return of an investment account, open the actions menu → \"Link transfers\" → check the transactions matching deposits/withdrawals — direction (in/out) is auto-proposed",
|
"To compute the real return of an investment account, open the actions menu → \"Link transfers\" → check the transactions matching deposits/withdrawals — direction (in/out) is auto-proposed",
|
||||||
"The accounts table now shows Modified Dietz returns over 3M / 1Y / since inception, side-by-side with the unadjusted return",
|
"The accounts table now shows Modified Dietz returns over 3M / 1Y / since inception, side-by-side with the unadjusted return",
|
||||||
"To track an account security by security, create it detailed or open its actions menu → \"Detail into securities\"; at later snapshots, add each holding with its quantity, price and cost basis (the account value = the sum of its securities)",
|
"To edit an existing snapshot, click its point on the chart or use the date picker — the page opens in edit mode (date is immutable)",
|
||||||
"To edit an existing snapshot, click its point on the chart or use the date picker — the page opens in edit mode. You can also fix the date: change it then save, and the snapshot is moved with its lines (a warning shows if the target date is already taken)",
|
|
||||||
"To delete a snapshot, click \"Delete\" in its editor and retype the date to confirm"
|
"To delete a snapshot, click \"Delete\" in its editor and retype the date to confirm"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
|
|
@ -1000,9 +978,7 @@
|
||||||
"Vertical chart markers help you read value jumps: a jump followed by a green marker isn't \"performance\", it's a deposit",
|
"Vertical chart markers help you read value jumps: a jump followed by a green marker isn't \"performance\", it's a deposit",
|
||||||
"If you try to delete a transaction linked to a balance account, the app asks you to unlink it first — this friction preserves the reproducibility of past returns",
|
"If you try to delete a transaction linked to a balance account, the app asks you to unlink it first — this friction preserves the reproducibility of past returns",
|
||||||
"The \"balance out of date\" warning appears if your latest snapshot is more than 60 days old",
|
"The \"balance out of date\" warning appears if your latest snapshot is more than 60 days old",
|
||||||
"Unrealized gain ≠ return: the unrealized gain (value − cost basis) measures your gain since purchase, while the Modified Dietz return measures performance accounting for the timing of your contributions — both show on a detailed account. Adjust the cost basis after a buy/sell, otherwise the unrealized gain drifts",
|
"(Coming in Phase 5) Automatic price fetching for Stocks/Crypto via a private proxy (premium-only) that anonymizes your request — manual entry remains always available"
|
||||||
"(Coming in Phase 5) Automatic price fetching for Stocks/Crypto via a private proxy (premium-only) that anonymizes your request — manual entry remains always available",
|
|
||||||
"Toggle the stacked chart axis between \"by asset class\" and \"by envelope\" to read both sides of your net worth. Note: a snapshot taken before the class/envelope split shows under \"Other\" on the by-asset-class axis (the former TFSA/RRSP types became envelopes) — this is expected; the by-envelope axis still surfaces your TFSA/RRSP, and your amounts are unchanged"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
@ -1452,7 +1428,6 @@
|
||||||
"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.",
|
||||||
|
|
@ -1573,7 +1548,9 @@
|
||||||
"latestValue": "Latest value",
|
"latestValue": "Latest value",
|
||||||
"periodDelta": "Δ% over period",
|
"periodDelta": "Δ% over period",
|
||||||
"noAccounts": "No active accounts. Create a balance account to get started.",
|
"noAccounts": "No active accounts. Create a balance account to get started.",
|
||||||
"accountsTitle": "Accounts"
|
"accountsTitle": "Accounts",
|
||||||
|
"detailAction": "Details",
|
||||||
|
"detailComingSoon": "Available in a future release."
|
||||||
},
|
},
|
||||||
"period": {
|
"period": {
|
||||||
"legend": "Analysis period",
|
"legend": "Analysis period",
|
||||||
|
|
@ -1586,37 +1563,26 @@
|
||||||
"chart": {
|
"chart": {
|
||||||
"empty": "No snapshot for this period.",
|
"empty": "No snapshot for this period.",
|
||||||
"modeLegend": "Chart display mode",
|
"modeLegend": "Chart display mode",
|
||||||
"axisLegend": "Stacked chart grouping axis",
|
|
||||||
"totalSeriesLabel": "Total",
|
"totalSeriesLabel": "Total",
|
||||||
"mode": {
|
"mode": {
|
||||||
"line": "Line",
|
"line": "Line",
|
||||||
"stacked": "Stacked by type"
|
"stacked": "Stacked by category"
|
||||||
},
|
|
||||||
"axis": {
|
|
||||||
"byAssetClass": "By asset class",
|
|
||||||
"byVehicle": "By envelope"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hub": {
|
"onboarding": {
|
||||||
"getStarted": "Get started",
|
"title": "Get started with your balance sheet",
|
||||||
"manage": "Manage",
|
"subtitle": "Two steps to start tracking your net worth.",
|
||||||
"accounts": {
|
"doneBadge": "Done",
|
||||||
"title": "Manage accounts",
|
"step1": {
|
||||||
"description": "Create, edit, or archive your accounts and asset types."
|
"title": "Create an account",
|
||||||
|
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
|
||||||
|
"cta": "Create an account"
|
||||||
},
|
},
|
||||||
"snapshot": {
|
"step2": {
|
||||||
"title": "New snapshot",
|
"title": "Enter a snapshot",
|
||||||
"description": "Record the balance of each account at a given date."
|
"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.",
|
||||||
}
|
"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": {
|
||||||
|
|
@ -1640,7 +1606,7 @@
|
||||||
"title": "Balance accounts",
|
"title": "Balance accounts",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"accounts": "Accounts",
|
"accounts": "Accounts",
|
||||||
"categories": "Types"
|
"categories": "Categories"
|
||||||
},
|
},
|
||||||
"newAccount": "New account",
|
"newAccount": "New account",
|
||||||
"includeArchived": "Show archived accounts",
|
"includeArchived": "Show archived accounts",
|
||||||
|
|
@ -1649,7 +1615,7 @@
|
||||||
"account": {
|
"account": {
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"category": "Type",
|
"category": "Category",
|
||||||
"symbol": "Symbol",
|
"symbol": "Symbol",
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
|
@ -1666,44 +1632,27 @@
|
||||||
"form": {
|
"form": {
|
||||||
"createTitle": "New account",
|
"createTitle": "New account",
|
||||||
"editTitle": "Edit account",
|
"editTitle": "Edit account",
|
||||||
"category": "Type",
|
"category": "Category",
|
||||||
"noCategory": "(no type available)",
|
"noCategory": "(no category available)",
|
||||||
"name": "Account name",
|
"name": "Account name",
|
||||||
"nameRequired": "Name is required.",
|
"nameRequired": "Name is required.",
|
||||||
"symbol": "Symbol",
|
"symbol": "Symbol",
|
||||||
"symbolPricedHint": "optional — only needed for automatic price fetching",
|
"symbolPricedHint": "required for priced categories",
|
||||||
|
"symbolRequiredForPriced": "A symbol is required for priced categories.",
|
||||||
"symbolPlaceholderSimple": "Optional",
|
"symbolPlaceholderSimple": "Optional",
|
||||||
"symbolPlaceholderPriced": "e.g. AAPL, BTC-USD (optional)",
|
"symbolPlaceholderPriced": "e.g. AAPL, BTC-USD",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"currencyMvpNotice": "At the MVP, all accounts are in CAD. Multi-currency support will land in a later version.",
|
"currencyMvpNotice": "At the MVP, all accounts are in CAD. Multi-currency support will land in a later version.",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"create": "Create account",
|
"create": "Create account"
|
||||||
"vehicleType": {
|
|
||||||
"label": "Fiscal envelope",
|
|
||||||
"none": "None",
|
|
||||||
"unregistered": "Non-registered",
|
|
||||||
"tfsa": "TFSA",
|
|
||||||
"rrsp": "RRSP",
|
|
||||||
"rrif": "RRIF",
|
|
||||||
"fhsa": "FHSA",
|
|
||||||
"resp": "RESP",
|
|
||||||
"hint": "Tax shelter for this account (TFSA, RRSP…). Optional — the asset class stays the type."
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"label": "Entry mode",
|
|
||||||
"simple": "Single amount",
|
|
||||||
"detailed": "By title (securities)",
|
|
||||||
"hint": "Single amount = one total per snapshot. By title = a breakdown into individual securities (quantity × price).",
|
|
||||||
"detailedCreateHint": "New accounts start as a single amount; convert one to by-title from the accounts list."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"intro": "The types that ship with the app (TFSA, RRSP, Cash, etc.) cannot be deleted. You can create your own for special cases.",
|
"intro": "Seeded categories (TFSA, RRSP, Cash, etc.) ship with the app. You can create your own for special cases.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
"kind": "Entry mode",
|
"kind": "Kind",
|
||||||
"origin": "Origin",
|
"origin": "Origin",
|
||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
|
|
@ -1716,26 +1665,23 @@
|
||||||
"user": "Custom"
|
"user": "Custom"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"create": "New type",
|
"create": "New category",
|
||||||
"renamePrompt": "New label for this type",
|
"renamePrompt": "New label for this category",
|
||||||
"deleteConfirm": "Delete this type? This cannot be undone.",
|
"deleteConfirm": "Delete this category? This cannot be undone.",
|
||||||
"deleteSeedHint": "Standard types cannot be deleted.",
|
"deleteSeedHint": "Standard categories cannot be deleted.",
|
||||||
"deleteHasAccountsHint": "This type has {{count}} linked account(s) — archive or move them first."
|
"deleteHasAccountsHint": "This category has {{count}} linked account(s) — archive or move them first."
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"createTitle": "New type",
|
"createTitle": "New category",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
"keyPlaceholder": "e.g. lira, prpp",
|
"keyPlaceholder": "e.g. lira, prpp",
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
"labelPlaceholder": "e.g. LIRA, PRPP",
|
"labelPlaceholder": "e.g. LIRA, PRPP",
|
||||||
"kindLabel": "Entry mode",
|
"kindLabel": "Category kind",
|
||||||
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
|
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
|
||||||
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). A symbol is optional for linked accounts (only needed for automatic price fetching).",
|
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). Linked accounts will require a symbol.",
|
||||||
"simpleOnlyNotice": "Priced types (stocks, crypto) will be available in a future release.",
|
"simpleOnlyNotice": "Priced categories (stocks, crypto) will be available in a future release.",
|
||||||
"create": "Create type",
|
"create": "Create category"
|
||||||
"customLabel": "Label",
|
|
||||||
"customLabelPlaceholder": "e.g. My RRIF, Travel savings",
|
|
||||||
"customLabelHint": "Display name for this type. Leave blank to use the default label."
|
|
||||||
},
|
},
|
||||||
"assetType": {
|
"assetType": {
|
||||||
"label": "Asset type",
|
"label": "Asset type",
|
||||||
|
|
@ -1744,12 +1690,12 @@
|
||||||
"required": "Select an asset type"
|
"required": "Select an asset type"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"has_accounts": "Cannot delete this type: {{count}} linked account(s) ({{names}}). Archive or move them first."
|
"has_accounts": "Cannot delete this category: {{count}} linked account(s) ({{names}}). Archive or move them first."
|
||||||
},
|
},
|
||||||
"cash": "Cash",
|
"cash": "Cash",
|
||||||
"tfsa": "TFSA",
|
"tfsa": "TFSA",
|
||||||
"rrsp": "RRSP",
|
"rrsp": "RRSP",
|
||||||
"fund": "Funds / ETF",
|
"fund": "Mutual fund",
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"stock": "Stock",
|
"stock": "Stock",
|
||||||
"crypto": "Crypto"
|
"crypto": "Crypto"
|
||||||
|
|
@ -1759,7 +1705,7 @@
|
||||||
"newTitle": "New snapshot",
|
"newTitle": "New snapshot",
|
||||||
"editTitle": "Edit snapshot",
|
"editTitle": "Edit snapshot",
|
||||||
"dateLabel": "Snapshot date",
|
"dateLabel": "Snapshot date",
|
||||||
"dateMovableHint": "You can change this snapshot's date. Entered values are kept and moved to the new date.",
|
"dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.",
|
||||||
"total": "Entered total",
|
"total": "Entered total",
|
||||||
"noAccounts": "To enter a snapshot, first create at least one account. An account = where you keep money (chequing, TFSA, RRSP, stocks, etc.). A snapshot = the picture of how much was in each account on a given date.",
|
"noAccounts": "To enter a snapshot, first create at least one account. An account = where you keep money (chequing, TFSA, RRSP, stocks, etc.). A snapshot = the picture of how much was in each account on a given date.",
|
||||||
"goToAccounts": "Create an account",
|
"goToAccounts": "Create an account",
|
||||||
|
|
@ -1790,56 +1736,6 @@
|
||||||
"attributionManual": "Manual",
|
"attributionManual": "Manual",
|
||||||
"attributionManualHint": "Value entered manually. Automatic price fetching will land in a later release."
|
"attributionManualHint": "Value entered manually. Automatic price fetching will land in a later release."
|
||||||
},
|
},
|
||||||
"detailed": {
|
|
||||||
"badge": "By title",
|
|
||||||
"badgeHint": "This account is broken down into individual securities. Its value is the sum of its positions.",
|
|
||||||
"empty": "No position yet. Add a title to record this account's holdings.",
|
|
||||||
"addTitle": "Add a title",
|
|
||||||
"removeTitle": "Remove this title",
|
|
||||||
"symbolLabel": "Security symbol",
|
|
||||||
"symbolPlaceholder": "Symbol",
|
|
||||||
"bookCostLabel": "Cost basis for {{account}}",
|
|
||||||
"bookCostPlaceholder": "0.00",
|
|
||||||
"latentGainLabel": "Unrealized gain for {{account}}",
|
|
||||||
"latentGainNA": "—",
|
|
||||||
"col": {
|
|
||||||
"title": "Security",
|
|
||||||
"quantity": "Quantity",
|
|
||||||
"unitPrice": "Price",
|
|
||||||
"value": "Value",
|
|
||||||
"bookCost": "Cost basis",
|
|
||||||
"latentGain": "Unrealized gain"
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"placeholder": "Symbol (e.g. AAPL, BTC)",
|
|
||||||
"empty": "Type a symbol to create it.",
|
|
||||||
"create": "Create \"{{symbol}}\"",
|
|
||||||
"assetTypeLabel": "Type:",
|
|
||||||
"assetType": {
|
|
||||||
"stock": "Stock",
|
|
||||||
"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": {
|
||||||
"title": "Delete this snapshot?",
|
"title": "Delete this snapshot?",
|
||||||
"body": "This permanently deletes the snapshot dated {{date}} and all its lines. To confirm, retype the date below.",
|
"body": "This permanently deletes the snapshot dated {{date}} and all its lines. To confirm, retype the date below.",
|
||||||
|
|
@ -1849,23 +1745,21 @@
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"currency_unsupported": "Only CAD is supported at the MVP.",
|
"currency_unsupported": "Only CAD is supported at the MVP.",
|
||||||
"category_seed_protected": "Standard types cannot be deleted.",
|
"category_seed_protected": "Standard categories cannot be deleted.",
|
||||||
"category_has_accounts": "Cannot delete a type with linked accounts. Move or archive linked accounts first.",
|
"category_has_accounts": "Cannot delete a category with linked accounts. Move or archive linked accounts first.",
|
||||||
"category_not_found": "Type not found.",
|
"category_not_found": "Category not found.",
|
||||||
"account_not_found": "Account not found.",
|
"account_not_found": "Account not found.",
|
||||||
"name_required": "Name is required.",
|
"name_required": "Name is required.",
|
||||||
"kind_invalid": "Invalid entry mode.",
|
"kind_invalid": "Invalid category kind.",
|
||||||
"snapshot_date_required": "A date in YYYY-MM-DD format is required.",
|
"snapshot_date_required": "A date in YYYY-MM-DD format is required.",
|
||||||
"snapshot_date_taken": "A snapshot already exists at that date — edit it instead of creating a new one.",
|
"snapshot_date_taken": "A snapshot already exists at that date — edit it instead of creating a new one.",
|
||||||
"snapshot_date_exists": "Another snapshot already exists at that date. Pick a free date or edit the existing snapshot.",
|
|
||||||
"snapshot_not_found": "Snapshot not found.",
|
"snapshot_not_found": "Snapshot not found.",
|
||||||
"snapshot_value_invalid": "An entered value is not a valid number.",
|
"snapshot_value_invalid": "An entered value is not a valid number.",
|
||||||
"snapshot_priced_unsupported": "Priced accounts (stocks/crypto) will be supported in a future release.",
|
"snapshot_priced_unsupported": "Priced accounts (stocks/crypto) will be supported in a future release.",
|
||||||
"snapshot_priced_quantity_required": "Quantity is required for priced accounts.",
|
"snapshot_priced_quantity_required": "Quantity is required for priced accounts.",
|
||||||
"snapshot_priced_unit_price_required": "Unit price is required for priced accounts.",
|
"snapshot_priced_unit_price_required": "Unit price is required for priced accounts.",
|
||||||
"snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.",
|
"snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.",
|
||||||
"snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price.",
|
"snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price."
|
||||||
"vehicle_type_invalid": "Invalid fiscal envelope."
|
|
||||||
},
|
},
|
||||||
"returns": {
|
"returns": {
|
||||||
"partialTooltip": "Partial return: a snapshot is missing for the selected period.",
|
"partialTooltip": "Partial return: a snapshot is missing for the selected period.",
|
||||||
|
|
@ -1879,47 +1773,7 @@
|
||||||
"sinceCreation": "Since inception",
|
"sinceCreation": "Since inception",
|
||||||
"sinceCreationTooltip": "Modified Dietz return since the first snapshot.",
|
"sinceCreationTooltip": "Modified Dietz return since the first snapshot.",
|
||||||
"unadjusted": "Unadjusted",
|
"unadjusted": "Unadjusted",
|
||||||
"unadjustedTooltip": "Simple return (V_end − V_start) / V_start, with no contribution weighting.",
|
"unadjustedTooltip": "Simple return (V_end − V_start) / V_start, with no contribution weighting."
|
||||||
"toggleReturns": {
|
|
||||||
"show": "Show returns",
|
|
||||||
"hide": "Hide returns"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"latentGain": {
|
|
||||||
"column": "Unrealized gain",
|
|
||||||
"tooltip": "Latent gain on a detailed account: current value minus cost basis (book cost), in dollars and percent. Shown “—” when the cost basis is unknown.",
|
|
||||||
"totalLabel": "Unrealized gain",
|
|
||||||
"na": "—",
|
|
||||||
"partial": "Partial: some positions have no recorded cost basis and are excluded from the percentage.",
|
|
||||||
"drilldown": {
|
|
||||||
"expand": "Show securities",
|
|
||||||
"collapse": "Hide securities",
|
|
||||||
"security": "Security",
|
|
||||||
"value": "Value",
|
|
||||||
"gain": "Unrealized gain"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"title": "Unrealized gain breakdown",
|
|
||||||
"byClass": "By asset class",
|
|
||||||
"byVehicle": "By envelope"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vehicle": {
|
|
||||||
"none": "No envelope"
|
|
||||||
},
|
|
||||||
"detailWizard": {
|
|
||||||
"action": "Detail into securities",
|
|
||||||
"title": "Detail “{{account}}” into securities",
|
|
||||||
"intro": "This account will switch to per-security detailed entry. Securities are entered at the next regular snapshot — this wizard captures none right now.",
|
|
||||||
"pointFrozen": "Past aggregated history stays frozen read-only: older snapshots keep their single total value.",
|
|
||||||
"pointNextSnapshot": "From today on, every new snapshot will require the per-security breakdown (with cost basis).",
|
|
||||||
"pointPivot": "Pivot date: {{date}}.",
|
|
||||||
"irreversible": "One-way action: once securities are entered, this account can no longer return to aggregated entry.",
|
|
||||||
"confirm": "Detail",
|
|
||||||
"confirming": "Switching…",
|
|
||||||
"errors": {
|
|
||||||
"account_kind_detailed_has_holdings": "This account already has securities entered and can no longer return to aggregated entry."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"transfers": {
|
"transfers": {
|
||||||
"linkAction": "Link transfers",
|
"linkAction": "Link transfers",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"kpiSectionTitle": "Indicateurs clés",
|
"balance": "Solde",
|
||||||
"income": "Revenus",
|
"income": "Revenus",
|
||||||
"expenses": "Dépenses",
|
"expenses": "Dépenses",
|
||||||
"net": "Net",
|
"net": "Net",
|
||||||
|
|
@ -50,11 +50,10 @@
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment utiliser le tableau de bord",
|
"title": "Comment utiliser le tableau de bord",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Le filtre en haut choisit la plage de dates et les sources d'import à inclure",
|
"Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates",
|
||||||
"Les indicateurs clés (revenus, dépenses, net, taux d'épargne) portent sur le mois de référence choisi, avec leur variation vs le mois précédent et vs l'an dernier",
|
"Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée",
|
||||||
"La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import",
|
"Le graphique circulaire détaille vos dépenses par catégorie",
|
||||||
"Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
|
"Les transactions récentes sont listées en bas de page"
|
||||||
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -380,10 +379,6 @@
|
||||||
"budgetVsActual": "Budget vs Réel",
|
"budgetVsActual": "Budget vs Réel",
|
||||||
"subtotalsOnTop": "Sous-totaux en haut",
|
"subtotalsOnTop": "Sous-totaux en haut",
|
||||||
"subtotalsOnBottom": "Sous-totaux en bas",
|
"subtotalsOnBottom": "Sous-totaux en bas",
|
||||||
"collapse": {
|
|
||||||
"expandAll": "Tout déplier",
|
|
||||||
"collapseAll": "Tout replier"
|
|
||||||
},
|
|
||||||
"detail": {
|
"detail": {
|
||||||
"showAmounts": "Afficher les montants",
|
"showAmounts": "Afficher les montants",
|
||||||
"hideAmounts": "Masquer les montants"
|
"hideAmounts": "Masquer les montants"
|
||||||
|
|
@ -393,18 +388,15 @@
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
"all": "Toutes",
|
"all": "Toutes",
|
||||||
"none": "Aucune",
|
"none": "Aucune",
|
||||||
"allTypes": "Tous les types",
|
"allTypes": "Tous les types"
|
||||||
"accounts": {
|
|
||||||
"label": "Sources d'import",
|
|
||||||
"hint": "Aucune sélection = toutes les sources"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Mensuel",
|
"monthly": "Mensuel",
|
||||||
"ytd": "Cumul annuel",
|
"ytd": "Cumul annuel",
|
||||||
"dollarVar": "$ Écart",
|
"dollarVar": "$ Écart",
|
||||||
"pctVar": "% Écart",
|
"pctVar": "% Écart",
|
||||||
"noData": "Aucune donnée de budget ou de transaction pour cette période."
|
"noData": "Aucune donnée de budget ou de transaction pour cette période.",
|
||||||
|
"titlePrefix": "Budget vs Réel pour le mois de"
|
||||||
},
|
},
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
"month": "Mois",
|
"month": "Mois",
|
||||||
|
|
@ -435,16 +427,7 @@
|
||||||
"referenceMonth": "Mois de référence",
|
"referenceMonth": "Mois de référence",
|
||||||
"currentAmount": "Courant",
|
"currentAmount": "Courant",
|
||||||
"previousAmount": "Précédent",
|
"previousAmount": "Précédent",
|
||||||
"sections": {
|
"totalRow": "Total"
|
||||||
"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",
|
||||||
|
|
@ -962,36 +945,31 @@
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"title": "Bilan",
|
"title": "Bilan",
|
||||||
"overview": "Le Bilan est une vue patrimoniale : vous saisissez périodiquement un snapshot (relevé daté de votre patrimoine) de l'ensemble de vos comptes, suivez leur évolution dans le temps et calculez le vrai rendement de chaque compte d'investissement en liant les transferts (apports/retraits) aux comptes correspondants. Deux axes indépendants : la classe d'actif (le type du compte : Liquidités, Fonds / FNB, Actions, Crypto, Autres) et l'enveloppe fiscale (un attribut optionnel du compte : CELI, REER, FERR, CELIAPP, REEE, ou aucune). Un compte se saisit en montant unique (compte simple) ou titre par titre (compte détaillé : chaque valeur mobilière avec quantité × cours et coût d'acquisition, la valeur du compte étant la somme de ses positions).",
|
"overview": "Le Bilan est une vue patrimoniale : vous saisissez périodiquement un snapshot daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), suivez leur évolution dans le temps et calculez le vrai rendement de chaque compte d'investissement en liant les transferts (apports/retraits) aux comptes correspondants.",
|
||||||
"features": [
|
"features": [
|
||||||
"5 types standard pré-installés, qui sont des classes d'actif (Liquidités, Fonds / FNB, Actions, Crypto, Autres) — renommables, non-supprimables ; un type regroupe des comptes de même nature (distinct des catégories de transactions). Les enveloppes fiscales ne sont plus des types",
|
"7 catégories standard pré-installées (Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres) — renommables, non-supprimables",
|
||||||
"Création de types personnalisés avec choix simple (montant direct) ou priced (quantité × prix unitaire)",
|
"Création de catégories personnalisées avec choix simple (montant direct) ou priced (quantité × prix unitaire)",
|
||||||
"Comptes détaillés (par titre) : un compte peut contenir plusieurs valeurs mobilières — chaque titre a sa ligne (symbole, quantité, cours, valeur, coût d'acquisition, gain latent), la valeur du compte étant la somme de ses titres. Sélecteur de titre avec autocomplétion et création inline (symbole + classe d'actif Action/Crypto)",
|
"Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes",
|
||||||
"Gain latent par titre et agrégé (par compte, par classe d'actif, par enveloppe) : valeur − coût d'acquisition, en $ et en % ; drill-down par titre dans le tableau des comptes ; une position sans coût d'acquisition affiche « N/A » et est exclue du %",
|
"Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer",
|
||||||
"Assistant « Détailler en titres » : bascule un compte simple en compte détaillé à partir d'une date de bascule (pivot) ; l'historique agrégé passé reste figé en lecture seule",
|
"Bouton « Pré-remplir depuis le snapshot précédent » : copie les valeurs simples + les quantités priced",
|
||||||
"Comptes par type : nom, enveloppe fiscale optionnelle (Non-enregistré, CELI, REER, FERR, CELIAPP, REEE, ou aucune par défaut), symbole optionnel (même pour les types cotés, il ne sert qu'à la récupération automatique des prix), devise (CAD au MVP), notes",
|
|
||||||
"Renommer un type ne casse plus le bilingue : le nom personnalisé est stocké à part, la traduction FR/EN d'origine est préservée",
|
|
||||||
"Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer ; la date d'un snapshot existant peut être déplacée (lignes conservées) si la date cible est libre",
|
|
||||||
"Bouton « Pré-remplir depuis le snapshot précédent » : copie les valeurs simples, les quantités priced, et les titres/quantités/coûts d'acquisition des comptes détaillés (un titre à quantité 0 est ignoré)",
|
|
||||||
"Liaison de transactions existantes à un compte de bilan (modal avec filtres et sens auto-proposé)",
|
"Liaison de transactions existantes à un compte de bilan (modal avec filtres et sens auto-proposé)",
|
||||||
"Icône d'attribution dans la page Transactions pour les transactions liées à un transfert",
|
"Icône d'attribution dans la page Transactions pour les transactions liées à un transfert",
|
||||||
"Graphique d'évolution avec mode courbe ou aire empilée, avec un sous-choix d'axe : par classe d'actif (défaut) ou par enveloppe + marqueurs verticaux pour les transferts (vert = in, rouge = out)",
|
"Graphique d'évolution avec mode courbe ou aire empilée par catégorie + marqueurs verticaux pour les transferts (vert = in, rouge = out)",
|
||||||
"Tableau des comptes avec 3 colonnes de rendement Modified Dietz (3M / 1A / depuis création) + colonne rendement non-ajusté côte-à-côte, repliées par défaut (toggle, choix mémorisé)",
|
"Tableau des comptes avec 3 colonnes de rendement Modified Dietz (3M / 1A / depuis création) + colonne rendement non-ajusté côte-à-côte",
|
||||||
"Avertissement si le dernier snapshot remonte à plus de 60 jours",
|
"Avertissement si le dernier snapshot remonte à plus de 60 jours",
|
||||||
"Soft-delete des comptes (Archiver) — masqués des nouveaux snapshots, conservés dans l'historique",
|
"Soft-delete des comptes (Archiver) — masqués des nouveaux snapshots, conservés dans l'historique",
|
||||||
"Suppression d'un snapshot avec double-confirmation par re-saisie de la date",
|
"Suppression d'un snapshot avec double-confirmation par re-saisie de la date",
|
||||||
"Privacy-first : tout est local, aucun appel sortant au MVP"
|
"Privacy-first : tout est local, aucun appel sortant au MVP"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Allez dans /balance/accounts → onglet Types pour créer si besoin une classe d'actif supplémentaire en simple (montant direct) ou priced (quantité × prix unitaire)",
|
"Allez dans /balance/accounts → onglet Catégories pour créer si besoin une catégorie supplémentaire (FERR en simple, ou Stocks Wealthsimple en priced)",
|
||||||
"Allez dans l'onglet Comptes pour créer chaque compte (Tangerine rattaché à Liquidités, BTC Ledger rattaché à Crypto avec symbole BTC — optionnel). Choisissez l'enveloppe fiscale si le compte est logé dans un CELI/REER/etc., ou laissez « Aucune »",
|
"Allez dans l'onglet Comptes pour créer chaque compte (TFSA Tangerine rattaché à CELI, BTC Ledger rattaché à Crypto avec symbole BTC)",
|
||||||
"Cliquez « + Nouveau snapshot » depuis /balance pour ouvrir /balance/snapshot à la date du jour",
|
"Cliquez « + Nouveau snapshot » depuis /balance pour ouvrir /balance/snapshot à la date du jour",
|
||||||
"Remplissez les valeurs par compte (groupées par type). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée",
|
"Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée",
|
||||||
"Enregistrez. Le graphique sur /balance s'actualise immédiatement",
|
"Enregistrez. Le graphique sur /balance s'actualise immédiatement",
|
||||||
"Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions → « Lier transferts » → cochez les transactions qui correspondent à des apports/retraits — le sens (in/out) est proposé automatiquement",
|
"Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions → « Lier transferts » → cochez les transactions qui correspondent à des apports/retraits — le sens (in/out) est proposé automatiquement",
|
||||||
"Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création, avec le rendement non-ajusté côte-à-côte",
|
"Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création, avec le rendement non-ajusté côte-à-côte",
|
||||||
"Pour suivre un compte titre par titre, créez-le détaillé ou ouvrez son menu d'actions → « Détailler en titres » ; aux snapshots suivants, ajoutez chaque valeur mobilière avec sa quantité, son cours et son coût d'acquisition (la valeur du compte = la somme des titres)",
|
"Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)",
|
||||||
"Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition. Vous pouvez aussi corriger la date : changez-la puis enregistrez, le snapshot est déplacé avec ses lignes (message d'alerte si la date cible est déjà prise)",
|
|
||||||
"Pour supprimer un snapshot, cliquez « Supprimer » dans son éditeur et re-saisissez la date pour confirmer"
|
"Pour supprimer un snapshot, cliquez « Supprimer » dans son éditeur et re-saisissez la date pour confirmer"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
|
|
@ -1000,9 +978,7 @@
|
||||||
"Les marqueurs verticaux du graphique aident à lire les sauts de valeur : un saut suivi d'un marqueur vert n'est pas une « performance », c'est un dépôt",
|
"Les marqueurs verticaux du graphique aident à lire les sauts de valeur : un saut suivi d'un marqueur vert n'est pas une « performance », c'est un dépôt",
|
||||||
"Si vous tentez de supprimer une transaction liée à un compte de bilan, l'app vous demande de la délier d'abord — cette friction préserve la reproductibilité de vos rendements passés",
|
"Si vous tentez de supprimer une transaction liée à un compte de bilan, l'app vous demande de la délier d'abord — cette friction préserve la reproductibilité de vos rendements passés",
|
||||||
"L'avertissement « bilan pas à jour » apparaît si votre dernier snapshot remonte à plus de 60 jours",
|
"L'avertissement « bilan pas à jour » apparaît si votre dernier snapshot remonte à plus de 60 jours",
|
||||||
"Gain latent ≠ rendement : le gain latent (valeur − coût d'acquisition) mesure votre plus-value depuis l'achat, le rendement Modified Dietz mesure la performance en tenant compte du timing de vos apports — les deux s'affichent sur un compte détaillé. Ajustez le coût d'acquisition après un achat/vente, sinon le gain latent dérive",
|
"(À venir Phase 5) Récupération automatique des prix pour Actions/Crypto via un proxy privé (premium-only) qui anonymise votre requête — la saisie manuelle reste toujours disponible"
|
||||||
"(À venir Phase 5) Récupération automatique des prix pour Actions/Crypto via un proxy privé (premium-only) qui anonymise votre requête — la saisie manuelle reste toujours disponible",
|
|
||||||
"Basculez l'axe du graphique empilé entre « par classe d'actif » et « par enveloppe » pour lire les deux faces du patrimoine. Note : un snapshot saisi avant la séparation classe/enveloppe apparaît sous « Autres » sur l'axe par classe d'actif (les ex-types CELI/REER sont devenus des enveloppes) — c'est attendu, l'axe par enveloppe retrouve bien vos CELI/REER, vos montants ne changent pas"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
@ -1452,7 +1428,6 @@
|
||||||
"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.",
|
||||||
|
|
@ -1566,14 +1541,16 @@
|
||||||
"title": "Bilan",
|
"title": "Bilan",
|
||||||
"latestTotal": "Valeur nette actuelle",
|
"latestTotal": "Valeur nette actuelle",
|
||||||
"asOf": "au {{date}}",
|
"asOf": "au {{date}}",
|
||||||
"noSnapshots": "Aucun snapshot (relevé daté de votre patrimoine) pour l'instant. Créez-en un pour suivre l'évolution de votre bilan.",
|
"noSnapshots": "Aucun snapshot pour l'instant. Créez-en un pour suivre l'évolution de votre bilan.",
|
||||||
"vsPrevious": "vs précédent",
|
"vsPrevious": "vs précédent",
|
||||||
"newSnapshot": "Nouveau snapshot",
|
"newSnapshot": "Nouveau snapshot",
|
||||||
"staleWarning": "Le dernier snapshot date de plus de {{days}} jours. Pensez à le mettre à jour pour suivre fidèlement l'évolution de votre bilan.",
|
"staleWarning": "Le dernier snapshot date de plus de {{days}} jours. Pensez à le mettre à jour pour suivre fidèlement l'évolution de votre bilan.",
|
||||||
"latestValue": "Dernière valeur",
|
"latestValue": "Dernière valeur",
|
||||||
"periodDelta": "Δ% sur la période",
|
"periodDelta": "Δ% sur la période",
|
||||||
"noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.",
|
"noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.",
|
||||||
"accountsTitle": "Comptes"
|
"accountsTitle": "Comptes",
|
||||||
|
"detailAction": "Détail",
|
||||||
|
"detailComingSoon": "Disponible dans une prochaine version."
|
||||||
},
|
},
|
||||||
"period": {
|
"period": {
|
||||||
"legend": "Période d'analyse",
|
"legend": "Période d'analyse",
|
||||||
|
|
@ -1586,37 +1563,26 @@
|
||||||
"chart": {
|
"chart": {
|
||||||
"empty": "Aucun snapshot pour cette période.",
|
"empty": "Aucun snapshot pour cette période.",
|
||||||
"modeLegend": "Mode d'affichage du graphique",
|
"modeLegend": "Mode d'affichage du graphique",
|
||||||
"axisLegend": "Axe de regroupement du graphique empilé",
|
|
||||||
"totalSeriesLabel": "Total",
|
"totalSeriesLabel": "Total",
|
||||||
"mode": {
|
"mode": {
|
||||||
"line": "Ligne",
|
"line": "Ligne",
|
||||||
"stacked": "Empilé par type"
|
"stacked": "Empilé par catégorie"
|
||||||
},
|
|
||||||
"axis": {
|
|
||||||
"byAssetClass": "Par classe d'actif",
|
|
||||||
"byVehicle": "Par enveloppe"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hub": {
|
"onboarding": {
|
||||||
"getStarted": "Commencer",
|
"title": "Premiers pas avec le bilan",
|
||||||
"manage": "Gérer",
|
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.",
|
||||||
"accounts": {
|
"doneBadge": "Fait",
|
||||||
"title": "Gérer les comptes",
|
"step1": {
|
||||||
"description": "Créez, modifiez ou archivez vos comptes et vos types d'actif."
|
"title": "Créer un compte",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"snapshot": {
|
"step2": {
|
||||||
"title": "Nouveau snapshot",
|
"title": "Saisir un snapshot",
|
||||||
"description": "Enregistrez le solde de chaque compte à une date donnée."
|
"description": "Un snapshot est la photo, à une date donnée, du solde de chaque compte. Saisissez-en un par mois pour suivre l'évolution.",
|
||||||
}
|
"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": {
|
||||||
|
|
@ -1640,7 +1606,7 @@
|
||||||
"title": "Comptes du bilan",
|
"title": "Comptes du bilan",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"accounts": "Comptes",
|
"accounts": "Comptes",
|
||||||
"categories": "Types"
|
"categories": "Catégories"
|
||||||
},
|
},
|
||||||
"newAccount": "Nouveau compte",
|
"newAccount": "Nouveau compte",
|
||||||
"includeArchived": "Afficher les comptes archivés",
|
"includeArchived": "Afficher les comptes archivés",
|
||||||
|
|
@ -1649,7 +1615,7 @@
|
||||||
"account": {
|
"account": {
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"category": "Type",
|
"category": "Catégorie",
|
||||||
"symbol": "Symbole",
|
"symbol": "Symbole",
|
||||||
"currency": "Devise",
|
"currency": "Devise",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
|
|
@ -1666,44 +1632,27 @@
|
||||||
"form": {
|
"form": {
|
||||||
"createTitle": "Nouveau compte",
|
"createTitle": "Nouveau compte",
|
||||||
"editTitle": "Modifier le compte",
|
"editTitle": "Modifier le compte",
|
||||||
"category": "Type",
|
"category": "Catégorie",
|
||||||
"noCategory": "(aucun type disponible)",
|
"noCategory": "(aucune catégorie disponible)",
|
||||||
"name": "Nom du compte",
|
"name": "Nom du compte",
|
||||||
"nameRequired": "Le nom est obligatoire.",
|
"nameRequired": "Le nom est obligatoire.",
|
||||||
"symbol": "Symbole",
|
"symbol": "Symbole",
|
||||||
"symbolPricedHint": "optionnel — requis seulement pour la récupération automatique des prix",
|
"symbolPricedHint": "obligatoire pour cette catégorie cotée",
|
||||||
|
"symbolRequiredForPriced": "Un symbole est obligatoire pour les catégories cotées.",
|
||||||
"symbolPlaceholderSimple": "Optionnel",
|
"symbolPlaceholderSimple": "Optionnel",
|
||||||
"symbolPlaceholderPriced": "ex. AAPL, BTC-USD (optionnel)",
|
"symbolPlaceholderPriced": "ex. AAPL, BTC-USD",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"currencyMvpNotice": "Au MVP, tous les comptes sont en CAD. Le support multi-devises arrivera dans une version ultérieure.",
|
"currencyMvpNotice": "Au MVP, tous les comptes sont en CAD. Le support multi-devises arrivera dans une version ultérieure.",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"create": "Créer le compte",
|
"create": "Créer le compte"
|
||||||
"vehicleType": {
|
|
||||||
"label": "Véhicule fiscal",
|
|
||||||
"none": "Aucun",
|
|
||||||
"unregistered": "Non-enregistré",
|
|
||||||
"tfsa": "CELI",
|
|
||||||
"rrsp": "REER",
|
|
||||||
"rrif": "FERR",
|
|
||||||
"fhsa": "CELIAPP",
|
|
||||||
"resp": "REEE",
|
|
||||||
"hint": "Enveloppe fiscale du compte (CELI, REER…). Optionnel — la classe d'actif reste le type."
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"label": "Mode de saisie",
|
|
||||||
"simple": "Montant unique",
|
|
||||||
"detailed": "Par titre (valeurs mobilières)",
|
|
||||||
"hint": "Montant unique = un total par snapshot. Par titre = un détail en valeurs mobilières individuelles (quantité × cours).",
|
|
||||||
"detailedCreateHint": "Les nouveaux comptes démarrent en montant unique ; convertissez-en un en « par titre » depuis la liste des comptes."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"intro": "Les types fournis par l'application (CELI, REER, Liquidités, etc.) ne sont pas supprimables. Vous pouvez en créer de nouveaux pour vos cas particuliers.",
|
"intro": "Les catégories seedées (CELI, REER, Encaisse, etc.) sont fournies par l'application. Vous pouvez en créer de nouvelles pour vos cas particuliers.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"key": "Clé",
|
"key": "Clé",
|
||||||
"kind": "Mode de saisie",
|
"kind": "Type",
|
||||||
"origin": "Origine",
|
"origin": "Origine",
|
||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
|
|
@ -1713,29 +1662,26 @@
|
||||||
},
|
},
|
||||||
"origin": {
|
"origin": {
|
||||||
"seeded": "Standard",
|
"seeded": "Standard",
|
||||||
"user": "Personnalisé"
|
"user": "Personnalisée"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"create": "Nouveau type",
|
"create": "Nouvelle catégorie",
|
||||||
"renamePrompt": "Nouveau libellé pour ce type",
|
"renamePrompt": "Nouveau libellé pour cette catégorie",
|
||||||
"deleteConfirm": "Supprimer ce type ? Cette action est irréversible.",
|
"deleteConfirm": "Supprimer cette catégorie ? Cette action est irréversible.",
|
||||||
"deleteSeedHint": "Les types standard ne peuvent pas être supprimés.",
|
"deleteSeedHint": "Les catégories standard ne peuvent pas être supprimées.",
|
||||||
"deleteHasAccountsHint": "Ce type a {{count}} compte(s) lié(s) — archivez ou déplacez-les d'abord."
|
"deleteHasAccountsHint": "Cette catégorie a {{count}} compte(s) lié(s) — archivez ou déplacez-les d'abord."
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"createTitle": "Nouveau type",
|
"createTitle": "Nouvelle catégorie",
|
||||||
"key": "Clé",
|
"key": "Clé",
|
||||||
"keyPlaceholder": "ex. ferr, rpdb",
|
"keyPlaceholder": "ex. ferr, rpdb",
|
||||||
"label": "Libellé",
|
"label": "Libellé",
|
||||||
"labelPlaceholder": "ex. FERR, RPDB",
|
"labelPlaceholder": "ex. FERR, RPDB",
|
||||||
"kindLabel": "Mode de saisie",
|
"kindLabel": "Type de catégorie",
|
||||||
"kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).",
|
"kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).",
|
||||||
"kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole est optionnel pour les comptes liés (requis seulement pour la récupération automatique des prix).",
|
"kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole sera obligatoire pour les comptes liés.",
|
||||||
"simpleOnlyNotice": "Les types cotés (actions, crypto) seront disponibles dans une prochaine version.",
|
"simpleOnlyNotice": "Les catégories cotées (actions, crypto) seront disponibles dans une prochaine version.",
|
||||||
"create": "Créer le type",
|
"create": "Créer la catégorie"
|
||||||
"customLabel": "Libellé",
|
|
||||||
"customLabelPlaceholder": "ex. Mon FERR, Épargne voyage",
|
|
||||||
"customLabelHint": "Nom affiché pour ce type. Laisser vide pour utiliser le libellé par défaut."
|
|
||||||
},
|
},
|
||||||
"assetType": {
|
"assetType": {
|
||||||
"label": "Type d'actif",
|
"label": "Type d'actif",
|
||||||
|
|
@ -1744,12 +1690,12 @@
|
||||||
"required": "Sélectionne le type d'actif"
|
"required": "Sélectionne le type d'actif"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"has_accounts": "Impossible de supprimer ce type : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
|
"has_accounts": "Impossible de supprimer cette catégorie : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
|
||||||
},
|
},
|
||||||
"cash": "Liquidités",
|
"cash": "Encaisse",
|
||||||
"tfsa": "CELI",
|
"tfsa": "CELI",
|
||||||
"rrsp": "REER",
|
"rrsp": "REER",
|
||||||
"fund": "Fonds / FNB",
|
"fund": "Fonds commun",
|
||||||
"other": "Autre",
|
"other": "Autre",
|
||||||
"stock": "Action",
|
"stock": "Action",
|
||||||
"crypto": "Cryptomonnaie"
|
"crypto": "Cryptomonnaie"
|
||||||
|
|
@ -1759,7 +1705,7 @@
|
||||||
"newTitle": "Nouveau snapshot",
|
"newTitle": "Nouveau snapshot",
|
||||||
"editTitle": "Modifier le snapshot",
|
"editTitle": "Modifier le snapshot",
|
||||||
"dateLabel": "Date du snapshot",
|
"dateLabel": "Date du snapshot",
|
||||||
"dateMovableHint": "Vous pouvez changer la date de ce snapshot. Les valeurs saisies sont conservées et déplacées à la nouvelle date.",
|
"dateImmutable": "La date d'un snapshot existant ne peut pas être modifiée. Pour changer la date, supprimez ce snapshot et créez-en un nouveau.",
|
||||||
"total": "Total saisi",
|
"total": "Total saisi",
|
||||||
"noAccounts": "Pour saisir un snapshot, créez d'abord au moins un compte. Un compte = où vous tenez votre argent (chèque, CELI, REER, actions, etc.). Un snapshot = la photo de combien il y avait dans chaque compte à une date donnée.",
|
"noAccounts": "Pour saisir un snapshot, créez d'abord au moins un compte. Un compte = où vous tenez votre argent (chèque, CELI, REER, actions, etc.). Un snapshot = la photo de combien il y avait dans chaque compte à une date donnée.",
|
||||||
"goToAccounts": "Créer un compte",
|
"goToAccounts": "Créer un compte",
|
||||||
|
|
@ -1790,56 +1736,6 @@
|
||||||
"attributionManual": "Manuel",
|
"attributionManual": "Manuel",
|
||||||
"attributionManualHint": "Valeur saisie manuellement. La récupération automatique des prix arrivera dans une prochaine version."
|
"attributionManualHint": "Valeur saisie manuellement. La récupération automatique des prix arrivera dans une prochaine version."
|
||||||
},
|
},
|
||||||
"detailed": {
|
|
||||||
"badge": "Par titre",
|
|
||||||
"badgeHint": "Ce compte est détaillé en titres individuels. Sa valeur est la somme de ses positions.",
|
|
||||||
"empty": "Aucune position. Ajoutez un titre pour saisir le contenu de ce compte.",
|
|
||||||
"addTitle": "Ajouter un titre",
|
|
||||||
"removeTitle": "Retirer ce titre",
|
|
||||||
"symbolLabel": "Symbole du titre",
|
|
||||||
"symbolPlaceholder": "Symbole",
|
|
||||||
"bookCostLabel": "Coût d'acquisition pour {{account}}",
|
|
||||||
"bookCostPlaceholder": "0,00",
|
|
||||||
"latentGainLabel": "Gain latent pour {{account}}",
|
|
||||||
"latentGainNA": "—",
|
|
||||||
"col": {
|
|
||||||
"title": "Titre",
|
|
||||||
"quantity": "Quantité",
|
|
||||||
"unitPrice": "Cours",
|
|
||||||
"value": "Valeur",
|
|
||||||
"bookCost": "Coût d'acquisition",
|
|
||||||
"latentGain": "Gain latent"
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"placeholder": "Symbole (ex. AAPL, BTC)",
|
|
||||||
"empty": "Tapez un symbole pour le créer.",
|
|
||||||
"create": "Créer « {{symbol}} »",
|
|
||||||
"assetTypeLabel": "Type :",
|
|
||||||
"assetType": {
|
|
||||||
"stock": "Action",
|
|
||||||
"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": {
|
||||||
"title": "Supprimer ce snapshot ?",
|
"title": "Supprimer ce snapshot ?",
|
||||||
"body": "Cette action supprime définitivement le snapshot du {{date}} et toutes ses lignes. Pour confirmer, retapez la date ci-dessous.",
|
"body": "Cette action supprime définitivement le snapshot du {{date}} et toutes ses lignes. Pour confirmer, retapez la date ci-dessous.",
|
||||||
|
|
@ -1849,23 +1745,21 @@
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"currency_unsupported": "Seul le CAD est supporté au MVP.",
|
"currency_unsupported": "Seul le CAD est supporté au MVP.",
|
||||||
"category_seed_protected": "Les types standard ne peuvent pas être supprimés.",
|
"category_seed_protected": "Les catégories standard ne peuvent pas être supprimées.",
|
||||||
"category_has_accounts": "Impossible de supprimer un type avec des comptes liés. Déplacez ou archivez d'abord les comptes liés.",
|
"category_has_accounts": "Impossible de supprimer une catégorie avec des comptes liés. Déplacez ou archivez d'abord les comptes liés.",
|
||||||
"category_not_found": "Type introuvable.",
|
"category_not_found": "Catégorie introuvable.",
|
||||||
"account_not_found": "Compte introuvable.",
|
"account_not_found": "Compte introuvable.",
|
||||||
"name_required": "Le nom est obligatoire.",
|
"name_required": "Le nom est obligatoire.",
|
||||||
"kind_invalid": "Mode de saisie invalide.",
|
"kind_invalid": "Type de catégorie invalide.",
|
||||||
"snapshot_date_required": "Une date au format AAAA-MM-JJ est obligatoire.",
|
"snapshot_date_required": "Une date au format AAAA-MM-JJ est obligatoire.",
|
||||||
"snapshot_date_taken": "Un snapshot existe déjà à cette date — modifiez-le au lieu d'en créer un nouveau.",
|
"snapshot_date_taken": "Un snapshot existe déjà à cette date — modifiez-le au lieu d'en créer un nouveau.",
|
||||||
"snapshot_date_exists": "Un autre snapshot existe déjà à cette date. Choisissez une date libre ou modifiez le snapshot existant.",
|
|
||||||
"snapshot_not_found": "Snapshot introuvable.",
|
"snapshot_not_found": "Snapshot introuvable.",
|
||||||
"snapshot_value_invalid": "Une valeur saisie n'est pas un nombre valide.",
|
"snapshot_value_invalid": "Une valeur saisie n'est pas un nombre valide.",
|
||||||
"snapshot_priced_unsupported": "Les comptes cotés (actions/crypto) seront supportés dans une prochaine version.",
|
"snapshot_priced_unsupported": "Les comptes cotés (actions/crypto) seront supportés dans une prochaine version.",
|
||||||
"snapshot_priced_quantity_required": "La quantité est obligatoire pour les comptes cotés.",
|
"snapshot_priced_quantity_required": "La quantité est obligatoire pour les comptes cotés.",
|
||||||
"snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.",
|
"snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.",
|
||||||
"snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.",
|
"snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.",
|
||||||
"snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix.",
|
"snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix."
|
||||||
"vehicle_type_invalid": "Véhicule fiscal invalide."
|
|
||||||
},
|
},
|
||||||
"returns": {
|
"returns": {
|
||||||
"partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.",
|
"partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.",
|
||||||
|
|
@ -1879,47 +1773,7 @@
|
||||||
"sinceCreation": "Depuis création",
|
"sinceCreation": "Depuis création",
|
||||||
"sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.",
|
"sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.",
|
||||||
"unadjusted": "Non ajusté",
|
"unadjusted": "Non ajusté",
|
||||||
"unadjustedTooltip": "Rendement simple (V_fin − V_début) / V_début, sans pondération des apports.",
|
"unadjustedTooltip": "Rendement simple (V_fin − V_début) / V_début, sans pondération des apports."
|
||||||
"toggleReturns": {
|
|
||||||
"show": "Afficher les rendements",
|
|
||||||
"hide": "Masquer les rendements"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"latentGain": {
|
|
||||||
"column": "Gain latent",
|
|
||||||
"tooltip": "Gain latent d'un compte détaillé : valeur actuelle moins le coût d'acquisition, en dollars et en pourcentage. Affiché « — » quand le coût d'acquisition est inconnu.",
|
|
||||||
"totalLabel": "Gain latent",
|
|
||||||
"na": "—",
|
|
||||||
"partial": "Partiel : certaines positions n'ont pas de coût d'acquisition saisi et sont exclues du pourcentage.",
|
|
||||||
"drilldown": {
|
|
||||||
"expand": "Afficher les titres",
|
|
||||||
"collapse": "Masquer les titres",
|
|
||||||
"security": "Titre",
|
|
||||||
"value": "Valeur",
|
|
||||||
"gain": "Gain latent"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"title": "Répartition du gain latent",
|
|
||||||
"byClass": "Par classe d'actif",
|
|
||||||
"byVehicle": "Par enveloppe"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vehicle": {
|
|
||||||
"none": "Sans enveloppe"
|
|
||||||
},
|
|
||||||
"detailWizard": {
|
|
||||||
"action": "Détailler en titres",
|
|
||||||
"title": "Détailler « {{account}} » en titres",
|
|
||||||
"intro": "Ce compte passera en saisie détaillée par titre. Les titres se saisissent au prochain instantané (snapshot) normal — cet assistant ne capture aucun titre maintenant.",
|
|
||||||
"pointFrozen": "L'historique agrégé passé reste figé en lecture seule : les anciens instantanés conservent leur valeur globale.",
|
|
||||||
"pointNextSnapshot": "À partir d'aujourd'hui, chaque nouvel instantané exigera le détail par titre (avec coût d'acquisition).",
|
|
||||||
"pointPivot": "Date de bascule (pivot) : {{date}}.",
|
|
||||||
"irreversible": "Action à sens unique : une fois des titres saisis, ce compte ne pourra plus revenir en saisie agrégée.",
|
|
||||||
"confirm": "Détailler",
|
|
||||||
"confirming": "Bascule…",
|
|
||||||
"errors": {
|
|
||||||
"account_kind_detailed_has_holdings": "Ce compte a déjà des titres saisis et ne peut plus revenir en saisie agrégée."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"transfers": {
|
"transfers": {
|
||||||
"linkAction": "Lier transferts",
|
"linkAction": "Lier transferts",
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ import type {
|
||||||
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
|
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
|
||||||
import AccountForm from "../components/balance/AccountForm";
|
import AccountForm from "../components/balance/AccountForm";
|
||||||
import type { CreateBalanceCategoryInput } from "../services/balance.service";
|
import type { CreateBalanceCategoryInput } from "../services/balance.service";
|
||||||
import {
|
|
||||||
renderCategoryLabelFromAccount,
|
|
||||||
renderCategoryLabelFromCategory,
|
|
||||||
} from "../utils/renderCategoryLabel";
|
|
||||||
|
|
||||||
type Tab = "accounts" | "categories";
|
type Tab = "accounts" | "categories";
|
||||||
|
|
||||||
|
|
@ -70,7 +66,7 @@ export default function AccountsPage() {
|
||||||
}, [state.accounts]);
|
}, [state.accounts]);
|
||||||
|
|
||||||
const renderCategoryLabel = (cat: BalanceCategory) =>
|
const renderCategoryLabel = (cat: BalanceCategory) =>
|
||||||
renderCategoryLabelFromCategory(cat, t);
|
t(cat.i18n_key, { defaultValue: cat.key });
|
||||||
|
|
||||||
const closeAccountForm = () => {
|
const closeAccountForm = () => {
|
||||||
setShowAccountForm(false);
|
setShowAccountForm(false);
|
||||||
|
|
@ -255,7 +251,9 @@ export default function AccountsPage() {
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{renderCategoryLabelFromAccount(acc, t)}
|
{t(acc.category_i18n_key, {
|
||||||
|
defaultValue: acc.category_key,
|
||||||
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
||||||
{acc.symbol ?? "—"}
|
{acc.symbol ?? "—"}
|
||||||
|
|
@ -414,14 +412,8 @@ export default function AccountsPage() {
|
||||||
t("balance.category.actions.renamePrompt"),
|
t("balance.category.actions.renamePrompt"),
|
||||||
renderCategoryLabel(cat)
|
renderCategoryLabel(cat)
|
||||||
);
|
);
|
||||||
// Write the human label to custom_label, never to
|
if (next && next.trim()) {
|
||||||
// i18n_key — preserves the bundled translation
|
editCategory(cat.id, { i18n_key: next.trim() });
|
||||||
// (fixes bug I). A blank/empty answer clears the
|
|
||||||
// override and falls back to t(i18n_key).
|
|
||||||
if (next !== null) {
|
|
||||||
editCategory(cat.id, {
|
|
||||||
custom_label: next.trim() || null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,12 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet, FilePlus } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useBalanceOverview,
|
useBalanceOverview,
|
||||||
type BalancePeriod,
|
type BalancePeriod,
|
||||||
type BalanceChartMode,
|
type BalanceChartMode,
|
||||||
type BalanceGroupAxis,
|
|
||||||
} from "../hooks/useBalanceOverview";
|
} from "../hooks/useBalanceOverview";
|
||||||
import { BALANCE_VEHICLE_TYPES } from "../shared/types";
|
|
||||||
import { VEHICLE_NONE_BUCKET } from "../services/balance.service";
|
|
||||||
import {
|
import {
|
||||||
archiveBalanceAccount,
|
archiveBalanceAccount,
|
||||||
listAccountTransfers,
|
listAccountTransfers,
|
||||||
|
|
@ -30,17 +27,12 @@ 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 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";
|
|
||||||
|
|
||||||
const STARTER_PREF_KEY = "balance_starter_proposed";
|
const STARTER_PREF_KEY = "balance_starter_proposed";
|
||||||
|
|
||||||
|
|
@ -48,18 +40,13 @@ const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
||||||
|
|
||||||
export default function BalancePage() {
|
export default function BalancePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setPeriod, setChartMode, setGroupAxis, reload } =
|
const { state, setPeriod, setChartMode, reload } = useBalanceOverview();
|
||||||
useBalanceOverview();
|
|
||||||
|
|
||||||
// Issue #142 — link-transfers modal state. Categories list is loaded once
|
// Issue #142 — link-transfers modal state. Categories list is loaded once
|
||||||
// on mount (used by the modal's filter dropdown).
|
// on mount (used by the modal's filter dropdown).
|
||||||
const [linkTarget, setLinkTarget] = useState<AccountLatestSnapshot | null>(
|
const [linkTarget, setLinkTarget] = useState<AccountLatestSnapshot | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
// Issue #215 — "détailler en titres" wizard target (a simple account being
|
|
||||||
// flipped to detailed entry mode).
|
|
||||||
const [detailTarget, setDetailTarget] =
|
|
||||||
useState<AccountLatestSnapshot | null>(null);
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [transfersByAccount, setTransfersByAccount] = useState<
|
const [transfersByAccount, setTransfersByAccount] = useState<
|
||||||
Map<number, BalanceAccountTransferWithTransaction[]>
|
Map<number, BalanceAccountTransferWithTransaction[]>
|
||||||
|
|
@ -156,26 +143,14 @@ export default function BalancePage() {
|
||||||
const m: Record<string, string> = {};
|
const m: Record<string, string> = {};
|
||||||
for (const a of state.accountsLatest) {
|
for (const a of state.accountsLatest) {
|
||||||
if (!m[a.category_key]) {
|
if (!m[a.category_key]) {
|
||||||
m[a.category_key] = renderCategoryLabelFromAccount(a, t);
|
m[a.category_key] = t(a.category_i18n_key, {
|
||||||
|
defaultValue: a.category_key,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
}, [state.accountsLatest, t]);
|
}, [state.accountsLatest, t]);
|
||||||
|
|
||||||
// Map vehicle_key → translated label for the "par enveloppe" stacked axis.
|
|
||||||
// Reuses the #203 account-form labels (`balance.account.form.vehicleType.*`)
|
|
||||||
// so the legend never duplicates strings; the NULL-envelope bucket uses the
|
|
||||||
// dedicated `balance.vehicle.none` key.
|
|
||||||
const vehicleLabels = useMemo(() => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
[VEHICLE_NONE_BUCKET]: t("balance.vehicle.none"),
|
|
||||||
};
|
|
||||||
for (const v of BALANCE_VEHICLE_TYPES) {
|
|
||||||
m[v] = t(`balance.account.form.vehicleType.${v}`);
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const handleArchiveAccount = async (accountId: number) => {
|
const handleArchiveAccount = async (accountId: number) => {
|
||||||
try {
|
try {
|
||||||
await archiveBalanceAccount(accountId);
|
await archiveBalanceAccount(accountId);
|
||||||
|
|
@ -198,98 +173,32 @@ export default function BalancePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Issue #244 — landing state. We probe accountsLatest for ANY snapshot
|
{/* Issue #178 — empty-state guard. We probe accountsLatest for ANY
|
||||||
date (independent of the active period filter, state.period) and
|
snapshot date so the guard is independent of the active period
|
||||||
derive one of three states via `deriveLandingState`. The old boolean
|
filter (state.period). When empty, we render only the onboarding
|
||||||
guard conflated "no account" with "accounts but no snapshot" and
|
card — period selector, chart and accounts table would all show
|
||||||
stranded the latter on a snapshot-only onboarding card, leaving the
|
empty states stacked under it (S2 from #187). */}
|
||||||
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 landingState = deriveLandingState(accountsCount, hasAnySnapshot);
|
const isEmpty = accountsCount === 0 || !hasAnySnapshot;
|
||||||
|
|
||||||
// Navigation tiles reused across states. "Manage accounts" is present
|
if (isEmpty) {
|
||||||
// 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">
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
<BalanceOnboardingCard
|
||||||
<h2 className="text-lg font-semibold mb-1">
|
accountsCount={accountsCount}
|
||||||
{t("balance.landing.empty.title")}
|
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<BalanceOverviewCard
|
<BalanceOverviewCard totals={state.evolutionTotals} />
|
||||||
totals={state.evolutionTotals}
|
|
||||||
latentGainRollup={state.latentGainRollup}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
{/* Period selector */}
|
{/* Period selector */}
|
||||||
|
|
@ -315,70 +224,35 @@ export default function BalancePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
{/* Chart mode toggle */}
|
||||||
{/* Stacked-mode group-axis sub-toggle (asset class / envelope).
|
<div
|
||||||
Only meaningful while the stacked mode is active. */}
|
role="group"
|
||||||
{state.chartMode === "stacked" && (
|
aria-label={t("balance.chart.modeLegend")}
|
||||||
<div
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
role="group"
|
>
|
||||||
aria-label={t("balance.chart.axisLegend")}
|
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
||||||
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChartMode(mode)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.chartMode === mode
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.chartMode === mode}
|
||||||
>
|
>
|
||||||
{(["class", "vehicle"] as BalanceGroupAxis[]).map((axis) => (
|
{t(`balance.chart.mode.${mode}`)}
|
||||||
<button
|
</button>
|
||||||
key={axis}
|
))}
|
||||||
type="button"
|
|
||||||
onClick={() => setGroupAxis(axis)}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium ${
|
|
||||||
state.groupAxis === axis
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
aria-pressed={state.groupAxis === axis}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
axis === "class"
|
|
||||||
? "balance.chart.axis.byAssetClass"
|
|
||||||
: "balance.chart.axis.byVehicle"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chart mode toggle */}
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
aria-label={t("balance.chart.modeLegend")}
|
|
||||||
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
|
||||||
>
|
|
||||||
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setChartMode(mode)}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium ${
|
|
||||||
state.chartMode === mode
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
aria-pressed={state.chartMode === mode}
|
|
||||||
>
|
|
||||||
{t(`balance.chart.mode.${mode}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BalanceEvolutionChart
|
<BalanceEvolutionChart
|
||||||
mode={state.chartMode}
|
mode={state.chartMode}
|
||||||
groupAxis={state.groupAxis}
|
|
||||||
totals={state.evolutionTotals}
|
totals={state.evolutionTotals}
|
||||||
byCategory={state.evolutionByCategory}
|
byCategory={state.evolutionByCategory}
|
||||||
byVehicle={state.evolutionByVehicle}
|
|
||||||
categoryLabels={categoryLabels}
|
categoryLabels={categoryLabels}
|
||||||
vehicleLabels={vehicleLabels}
|
|
||||||
transferMarkers={allTransferMarkers}
|
transferMarkers={allTransferMarkers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -390,16 +264,10 @@ export default function BalancePage() {
|
||||||
accounts={state.accountsLatest}
|
accounts={state.accountsLatest}
|
||||||
periodAnchor={state.accountsPeriodAnchor}
|
periodAnchor={state.accountsPeriodAnchor}
|
||||||
sinceCreationDate={earliestSnapshotDate}
|
sinceCreationDate={earliestSnapshotDate}
|
||||||
latentGainByAccount={state.latentGainByAccount}
|
|
||||||
latentGainRollup={state.latentGainRollup}
|
|
||||||
vehicleLabels={vehicleLabels}
|
|
||||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||||
onDetailAccount={(acc) => setDetailTarget(acc)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderHub([accountsTile, snapshotTile], "balance.hub.manage")}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -422,17 +290,6 @@ export default function BalancePage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailTarget && (
|
|
||||||
<DetailAccountWizard
|
|
||||||
accountId={detailTarget.account_id}
|
|
||||||
accountName={detailTarget.account_name}
|
|
||||||
onClose={() => setDetailTarget(null)}
|
|
||||||
onDetailed={() => {
|
|
||||||
void reload();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import FilterPanel from "../components/reports/FilterPanel";
|
|
||||||
import { useBudget } from "../hooks/useBudget";
|
import { useBudget } from "../hooks/useBudget";
|
||||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
|
||||||
import YearNavigator from "../components/budget/YearNavigator";
|
import YearNavigator from "../components/budget/YearNavigator";
|
||||||
import BudgetTable from "../components/budget/BudgetTable";
|
import BudgetTable from "../components/budget/BudgetTable";
|
||||||
import TemplateActions from "../components/budget/TemplateActions";
|
import TemplateActions from "../components/budget/TemplateActions";
|
||||||
|
|
||||||
export default function BudgetPage() {
|
export default function BudgetPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { accountIds, setAccountIds } = useReportsPeriod();
|
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
navigateYear,
|
navigateYear,
|
||||||
|
|
@ -21,32 +18,28 @@ export default function BudgetPage() {
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
} = useBudget();
|
} = useBudget();
|
||||||
|
|
||||||
const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
|
const { year, rows, templates, isLoading, isSaving, error } = state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
|
||||||
<PageHelp helpKey="budget" />
|
<PageHelp helpKey="budget" />
|
||||||
</div>
|
</div>
|
||||||
<TemplateActions
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||||
templates={templates}
|
<TemplateActions
|
||||||
onApply={applyTemplate}
|
templates={templates}
|
||||||
onApplyAllMonths={applyTemplateAllMonths}
|
onApply={applyTemplate}
|
||||||
onSave={saveTemplate}
|
onApplyAllMonths={applyTemplateAllMonths}
|
||||||
onDelete={deleteTemplate}
|
onSave={saveTemplate}
|
||||||
disabled={isSaving}
|
onDelete={deleteTemplate}
|
||||||
/>
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<YearNavigator year={year} onNavigate={navigateYear} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterPanel
|
|
||||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
|
||||||
accountIds={accountIds}
|
|
||||||
onAccountIdsChange={setAccountIds}
|
|
||||||
accounts={accounts}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||||
{error}
|
{error}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,23 @@
|
||||||
// DashboardPage — home page at `/`.
|
|
||||||
//
|
|
||||||
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
|
|
||||||
// row, top movers and budget adherence are the exact Cartes presentational
|
|
||||||
// components, sourced from the same `getCartesSnapshot` reference-month
|
|
||||||
// snapshot. Two Dashboard-specific widgets remain: the top-expense-categories
|
|
||||||
// ranked bar chart (replacing the former expense-only pie) and the net-worth
|
|
||||||
// tile (from the Bilan — a different metric from every transactional widget
|
|
||||||
// here, see NetWorthTile's own header comment).
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
||||||
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
||||||
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
|
||||||
import FilterPanel from "../components/reports/FilterPanel";
|
|
||||||
import KpiCard from "../components/reports/cards/KpiCard";
|
|
||||||
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
|
||||||
import TopMoversList from "../components/reports/cards/TopMoversList";
|
|
||||||
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
|
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
import type { CategoryBreakdownItem } from "../shared/types";
|
import type { CategoryBreakdownItem } from "../shared/types";
|
||||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const {
|
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
|
||||||
state,
|
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
|
||||||
setPeriod,
|
|
||||||
setCustomDates,
|
|
||||||
setReferencePeriod,
|
|
||||||
setAccountIds,
|
|
||||||
} = useDashboard();
|
|
||||||
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -54,6 +37,35 @@ export default function DashboardPage() {
|
||||||
setDetailModal(item);
|
setDetailModal(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const balance = summary.totalAmount;
|
||||||
|
const balanceColor =
|
||||||
|
balance > 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: balance < 0
|
||||||
|
? "text-[var(--negative)]"
|
||||||
|
: "text-[var(--primary)]";
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
labelKey: "dashboard.balance",
|
||||||
|
value: fmt.format(balance),
|
||||||
|
icon: Wallet,
|
||||||
|
color: balanceColor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "dashboard.income",
|
||||||
|
value: fmt.format(summary.incomeTotal),
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: "text-[var(--positive)]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "dashboard.expenses",
|
||||||
|
value: fmt.format(Math.abs(summary.expenseTotal)),
|
||||||
|
icon: TrendingDown,
|
||||||
|
color: "text-[var(--negative)]",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
@ -61,119 +73,70 @@ export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<CategoriesV1DiscoveryBanner />
|
<CategoriesV1DiscoveryBanner />
|
||||||
<div className="relative flex items-center gap-3 mb-6">
|
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
<div className="flex items-center gap-3">
|
||||||
<PageHelp helpKey="dashboard" />
|
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||||
</div>
|
<PageHelp helpKey="dashboard" />
|
||||||
|
|
||||||
{state.error && (
|
|
||||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
|
||||||
{state.error}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<PeriodSelector
|
||||||
|
value={period}
|
||||||
<FilterPanel
|
onChange={setPeriod}
|
||||||
temporalControl={
|
customDateFrom={state.customDateFrom}
|
||||||
<PeriodSelector
|
customDateTo={state.customDateTo}
|
||||||
value={period}
|
onCustomDateChange={setCustomDates}
|
||||||
onChange={setPeriod}
|
|
||||||
customDateFrom={state.customDateFrom}
|
|
||||||
customDateTo={state.customDateTo}
|
|
||||||
onCustomDateChange={setCustomDates}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
accountIds={state.accountIds}
|
|
||||||
onAccountIdsChange={setAccountIds}
|
|
||||||
accounts={state.accounts}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
|
||||||
<h2 className="text-lg font-semibold">{t("dashboard.kpiSectionTitle")}</h2>
|
|
||||||
<select
|
|
||||||
value={`${state.referenceYear}-${state.referenceMonth}`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const [y, m] = e.target.value.split("-").map(Number);
|
|
||||||
setReferencePeriod(y, m);
|
|
||||||
}}
|
|
||||||
className="text-sm font-medium bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
{monthOptions.map((opt) => (
|
|
||||||
<option key={opt.key} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 mb-6">
|
|
||||||
<NetWorthTile data={state.netWorth} />
|
|
||||||
{cartesSnapshot && (
|
|
||||||
<>
|
|
||||||
<div className="flex-1 min-w-[180px]">
|
|
||||||
<KpiCard
|
|
||||||
id="income"
|
|
||||||
title={t("reports.cartes.income")}
|
|
||||||
kpi={cartesSnapshot.kpis.income}
|
|
||||||
format="currency"
|
|
||||||
deltaIsBadWhenUp={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-[180px]">
|
|
||||||
<KpiCard
|
|
||||||
id="expenses"
|
|
||||||
title={t("reports.cartes.expenses")}
|
|
||||||
kpi={cartesSnapshot.kpis.expenses}
|
|
||||||
format="currency"
|
|
||||||
deltaIsBadWhenUp={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-[180px]">
|
|
||||||
<KpiCard
|
|
||||||
id="net"
|
|
||||||
title={t("reports.cartes.net")}
|
|
||||||
kpi={cartesSnapshot.kpis.net}
|
|
||||||
format="currency"
|
|
||||||
deltaIsBadWhenUp={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-[180px]">
|
|
||||||
<KpiCard
|
|
||||||
id="savingsRate"
|
|
||||||
title={t("reports.cartes.savingsRate")}
|
|
||||||
kpi={cartesSnapshot.kpis.savingsRate}
|
|
||||||
format="percent"
|
|
||||||
deltaIsBadWhenUp={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cartesSnapshot && (
|
|
||||||
<>
|
|
||||||
<div className="mb-6">
|
|
||||||
<IncomeExpenseOverlayChart flow={cartesSnapshot.flow12Months} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
|
|
||||||
<TopMoversList movers={cartesSnapshot.topMoversUp} direction="up" />
|
|
||||||
<TopMoversList movers={cartesSnapshot.topMoversDown} direction="down" />
|
|
||||||
<BudgetAdherenceCard adherence={cartesSnapshot.budgetAdherence} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
|
||||||
<CategoryBarChart
|
|
||||||
data={categoryBreakdown}
|
|
||||||
hiddenCategories={hiddenCategories}
|
|
||||||
onToggleHidden={toggleHidden}
|
|
||||||
onShowAll={showAll}
|
|
||||||
onViewDetails={viewDetails}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.labelKey}
|
||||||
|
className="bg-[var(--card)] rounded-xl p-5 border border-[var(--border)] shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t(card.labelKey)}
|
||||||
|
</span>
|
||||||
|
<card.icon size={20} className={card.color} />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
||||||
|
<CategoryPieChart
|
||||||
|
data={categoryBreakdown}
|
||||||
|
hiddenCategories={hiddenCategories}
|
||||||
|
onToggleHidden={toggleHidden}
|
||||||
|
onShowAll={showAll}
|
||||||
|
onViewDetails={viewDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap">
|
||||||
|
{t("reports.bva.titlePrefix")}
|
||||||
|
<select
|
||||||
|
value={`${state.budgetYear}-${state.budgetMonth}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [y, m] = e.target.value.split("-").map(Number);
|
||||||
|
setBudgetMonth(y, m);
|
||||||
|
}}
|
||||||
|
className="text-base font-semibold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{monthOptions.map((opt) => (
|
||||||
|
<option key={opt.key} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</h2>
|
||||||
|
<BudgetVsActualTable data={budgetVsActual} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
|
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
|
||||||
<CategoryOverTimeChart
|
<CategoryOverTimeChart
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import FilterPanel from "../components/reports/FilterPanel";
|
|
||||||
import CompareModeTabs from "../components/reports/CompareModeTabs";
|
import CompareModeTabs from "../components/reports/CompareModeTabs";
|
||||||
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
|
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
|
||||||
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
||||||
|
|
@ -26,8 +25,7 @@ function formatMonthLabel(year: number, month: number, language: string): string
|
||||||
|
|
||||||
export default function ReportsComparePage() {
|
export default function ReportsComparePage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } =
|
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||||
useReportsPeriod();
|
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
subMode,
|
subMode,
|
||||||
|
|
@ -37,7 +35,6 @@ export default function ReportsComparePage() {
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
rows,
|
rows,
|
||||||
accounts,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useCompare();
|
} = useCompare();
|
||||||
|
|
@ -93,25 +90,21 @@ export default function ReportsComparePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterPanel
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
||||||
temporalControl={
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<CompareReferenceMonthPicker
|
<CompareReferenceMonthPicker
|
||||||
year={year}
|
year={year}
|
||||||
month={month}
|
month={month}
|
||||||
onChange={setReferencePeriod}
|
onChange={setReferencePeriod}
|
||||||
/>
|
/>
|
||||||
}
|
{showActualControls && (
|
||||||
accountIds={accountIds}
|
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
|
||||||
onAccountIdsChange={setAccountIds}
|
)}
|
||||||
accounts={accounts}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showActualControls && (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
|
||||||
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
|
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{showActualControls && (
|
||||||
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||||
|
|
@ -120,7 +113,7 @@ export default function ReportsComparePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === "budget" ? (
|
{mode === "budget" ? (
|
||||||
<CompareBudgetView year={year} month={month} accountIds={accountIds} />
|
<CompareBudgetView year={year} month={month} />
|
||||||
) : viewMode === "chart" ? (
|
) : viewMode === "chart" ? (
|
||||||
<ComparePeriodChart
|
<ComparePeriodChart
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import FilterPanel from "../components/reports/FilterPanel";
|
|
||||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
|
@ -22,9 +21,9 @@ const STORAGE_KEY = "reports-viewmode-trends";
|
||||||
|
|
||||||
export default function ReportsTrendsPage() {
|
export default function ReportsTrendsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
|
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||||
const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
|
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
||||||
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
|
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
|
@ -56,56 +55,50 @@ export default function ReportsTrendsPage() {
|
||||||
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
|
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterPanel
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
||||||
temporalControl={
|
<PeriodSelector
|
||||||
<PeriodSelector
|
value={period}
|
||||||
value={period}
|
onChange={setPeriod}
|
||||||
onChange={setPeriod}
|
customDateFrom={from}
|
||||||
customDateFrom={from}
|
customDateTo={to}
|
||||||
customDateTo={to}
|
onCustomDateChange={setCustomDates}
|
||||||
onCustomDateChange={setCustomDates}
|
/>
|
||||||
/>
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
}
|
<div className="inline-flex gap-1">
|
||||||
accountIds={accountIds}
|
<button
|
||||||
onAccountIdsChange={setAccountIds}
|
type="button"
|
||||||
accounts={accounts}
|
onClick={() => setSubView("global")}
|
||||||
/>
|
aria-pressed={subView === "global"}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
<div className="flex gap-2 items-center justify-end flex-wrap mb-6">
|
subView === "global"
|
||||||
<div className="inline-flex gap-1">
|
? "bg-[var(--primary)] text-white"
|
||||||
<button
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
type="button"
|
}`}
|
||||||
onClick={() => setSubView("global")}
|
>
|
||||||
aria-pressed={subView === "global"}
|
{t("reports.trends.subviewGlobal")}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
</button>
|
||||||
subView === "global"
|
<button
|
||||||
? "bg-[var(--primary)] text-white"
|
type="button"
|
||||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
onClick={() => setSubView("byCategory")}
|
||||||
}`}
|
aria-pressed={subView === "byCategory"}
|
||||||
>
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
{t("reports.trends.subviewGlobal")}
|
subView === "byCategory"
|
||||||
</button>
|
? "bg-[var(--primary)] text-white"
|
||||||
<button
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
type="button"
|
}`}
|
||||||
onClick={() => setSubView("byCategory")}
|
>
|
||||||
aria-pressed={subView === "byCategory"}
|
{t("reports.trends.subviewByCategory")}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
</button>
|
||||||
subView === "byCategory"
|
</div>
|
||||||
? "bg-[var(--primary)] text-white"
|
{subView === "byCategory" && viewMode === "chart" && (
|
||||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
<TrendsChartTypeToggle
|
||||||
}`}
|
value={chartType}
|
||||||
>
|
onChange={setChartType}
|
||||||
{t("reports.trends.subviewByCategory")}
|
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
|
||||||
</button>
|
/>
|
||||||
|
)}
|
||||||
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
</div>
|
</div>
|
||||||
{subView === "byCategory" && viewMode === "chart" && (
|
|
||||||
<TrendsChartTypeToggle
|
|
||||||
value={chartType}
|
|
||||||
onChange={setChartType}
|
|
||||||
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -130,7 +123,7 @@ export default function ReportsTrendsPage() {
|
||||||
chartType={chartType}
|
chartType={chartType}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CategoryOverTimeTable data={categoryOverTime} />
|
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,8 @@ export default function SnapshotEditPage() {
|
||||||
const isEditMode = state.mode === "edit";
|
const isEditMode = state.mode === "edit";
|
||||||
const canPrefill = !!state.previousSnapshot;
|
const canPrefill = !!state.previousSnapshot;
|
||||||
|
|
||||||
// Aggregate value across simple + detailed accounts (computed live as the
|
// Aggregate value across simple + priced lines (computed live as the
|
||||||
// user types). A detailed account contributes the sum of its holdings'
|
// user types). Priced contribution = quantity × unit_price.
|
||||||
// quantity × unit_price (#213).
|
|
||||||
const totalValue = useMemo(() => {
|
const totalValue = useMemo(() => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let hasAny = false;
|
let hasAny = false;
|
||||||
|
|
@ -64,30 +63,31 @@ export default function SnapshotEditPage() {
|
||||||
hasAny = true;
|
hasAny = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const basket of Object.values(state.holdings)) {
|
for (const entry of Object.values(state.pricedValues)) {
|
||||||
if (!basket) continue;
|
if (!entry) continue;
|
||||||
for (const h of basket) {
|
const qty = Number(String(entry.quantity ?? "").trim().replace(",", "."));
|
||||||
const qty = Number(String(h.quantity ?? "").trim().replace(",", "."));
|
const price = Number(
|
||||||
const price = Number(
|
String(entry.unit_price ?? "").trim().replace(",", ".")
|
||||||
String(h.unit_price ?? "").trim().replace(",", ".")
|
);
|
||||||
);
|
if (Number.isFinite(qty) && Number.isFinite(price)) {
|
||||||
if (Number.isFinite(qty) && Number.isFinite(price)) {
|
total += qty * price;
|
||||||
total += qty * price;
|
hasAny = true;
|
||||||
hasAny = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hasAny ? total : null;
|
return hasAny ? total : null;
|
||||||
}, [state.values, state.holdings]);
|
}, [state.values, state.pricedValues]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await editor.save();
|
await editor.save();
|
||||||
// Keep the URL in sync with the saved date so a reload reopens the
|
// After a successful create, the URL should become `?date=...` so
|
||||||
// right snapshot in edit mode. Covers both a fresh create (new → edit)
|
// refreshing keeps the user in edit mode.
|
||||||
// and an edit-mode date move (#200), where `?date=` still points at the
|
if (!isEditMode) {
|
||||||
// pre-move date until we update it here.
|
setSearchParams(
|
||||||
setSearchParams({ date: state.snapshotDate }, { replace: true });
|
{ date: state.snapshotDate },
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// The hook surfaced the error via state.errorCode/state.error.
|
// The hook surfaced the error via state.errorCode/state.error.
|
||||||
}
|
}
|
||||||
|
|
@ -144,32 +144,27 @@ export default function SnapshotEditPage() {
|
||||||
id="snapshot-date"
|
id="snapshot-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={state.snapshotDate}
|
value={state.snapshotDate}
|
||||||
|
disabled={isEditMode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
editor.setDate(next);
|
editor.setDate(next);
|
||||||
// In 'new' mode, drive the route param so reloads stay
|
// Drive the route param so reloads stay coherent and an
|
||||||
// coherent and an existing snapshot at the chosen date flips
|
// existing snapshot at the chosen date flips us into 'edit'.
|
||||||
// us into 'edit'. In 'edit' mode we deliberately DON'T sync
|
if (next) {
|
||||||
// the URL: changing the date is a pending "move" applied on
|
setSearchParams({ date: next }, { replace: true });
|
||||||
// save (#200), so reloading from the route would discard the
|
} else {
|
||||||
// edit and reopen the original snapshot.
|
setSearchParams({}, { replace: true });
|
||||||
if (!isEditMode) {
|
|
||||||
if (next) {
|
|
||||||
setSearchParams({ date: next }, { replace: true });
|
|
||||||
} else {
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// WebKitGTK (Linux Tauri WebView) does not always dismiss the
|
// WebKitGTK (Linux Tauri WebView) does not always dismiss the
|
||||||
// native date popup after a value commit — user has to hit
|
// native date popup after a value commit — user has to hit
|
||||||
// Esc. Force-blur is a no-op on WebView2/WKWebView. See #177.
|
// Esc. Force-blur is a no-op on WebView2/WKWebView. See #177.
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||||
{t("balance.snapshot.page.dateMovableHint")}
|
{t("balance.snapshot.page.dateImmutable")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,14 +200,10 @@ export default function SnapshotEditPage() {
|
||||||
accounts={state.accounts}
|
accounts={state.accounts}
|
||||||
categories={state.categories}
|
categories={state.categories}
|
||||||
values={state.values}
|
values={state.values}
|
||||||
holdings={state.holdings}
|
pricedValues={state.pricedValues}
|
||||||
securities={state.securities}
|
|
||||||
onValueChange={editor.setLineValue}
|
onValueChange={editor.setLineValue}
|
||||||
onAddHolding={editor.addHolding}
|
onQuantityChange={editor.setLineQuantity}
|
||||||
onRemoveHolding={editor.removeHolding}
|
onUnitPriceChange={editor.setLineUnitPrice}
|
||||||
onHoldingFieldChange={editor.setHoldingField}
|
|
||||||
onHoldingSecurityPick={editor.setHoldingSecurity}
|
|
||||||
onImportHoldings={editor.importHoldings}
|
|
||||||
disabled={state.isSaving}
|
disabled={state.isSaving}
|
||||||
snapshotDate={state.snapshotDate}
|
snapshotDate={state.snapshotDate}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,120 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
|
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
|
||||||
const getDb = vi.fn();
|
|
||||||
return { getDb };
|
|
||||||
});
|
|
||||||
|
|
||||||
import { getDb } from "./db";
|
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
|
||||||
const mockDb = { select: mockSelect };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
|
||||||
mockSelect.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories,
|
|
||||||
// getBudgetEntriesForYear, and getActualsByCategoryRange for the month + YTD
|
|
||||||
// windows). Only the two `getActualsByCategoryRange` calls query `transactions`
|
|
||||||
// and are eligible for the accountIds filter — budgets are not tied to an
|
|
||||||
// account, so the categories/entries queries must never carry the clause.
|
|
||||||
describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
|
|
||||||
it("without accountIds, the actuals queries carry no source_id clause (regression)", async () => {
|
|
||||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
|
||||||
|
|
||||||
await getBudgetVsActualData(2026, 3);
|
|
||||||
|
|
||||||
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
|
|
||||||
(sql as string).includes("FROM transactions"),
|
|
||||||
);
|
|
||||||
expect(actualsCalls.length).toBe(2); // month + YTD
|
|
||||||
for (const [sql, params] of actualsCalls) {
|
|
||||||
expect(sql as string).not.toContain("source_id");
|
|
||||||
expect(params as unknown[]).toHaveLength(2); // dateFrom, dateTo only
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
|
|
||||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
|
||||||
|
|
||||||
await getBudgetVsActualData(2026, 3, []);
|
|
||||||
|
|
||||||
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
|
|
||||||
(sql as string).includes("FROM transactions"),
|
|
||||||
);
|
|
||||||
for (const [sql, params] of actualsCalls) {
|
|
||||||
expect(sql as string).not.toContain("source_id");
|
|
||||||
expect(params as unknown[]).toHaveLength(2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies a parameterized accountIds IN clause to both the month and YTD actuals queries", async () => {
|
|
||||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
|
||||||
|
|
||||||
await getBudgetVsActualData(2026, 3, [4, 9]);
|
|
||||||
|
|
||||||
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
|
|
||||||
(sql as string).includes("FROM transactions"),
|
|
||||||
);
|
|
||||||
expect(actualsCalls.length).toBe(2);
|
|
||||||
for (const [sql, params] of actualsCalls) {
|
|
||||||
expect(sql as string).toContain("source_id IN ($3, $4)");
|
|
||||||
expect(sql as string).not.toContain("4,9");
|
|
||||||
expect(params as unknown[]).toEqual(expect.arrayContaining([4, 9]));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never adds the filter to the categories/budget-entries queries (a budget is not tied to an account)", async () => {
|
|
||||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
|
||||||
|
|
||||||
await getBudgetVsActualData(2026, 3, [4]);
|
|
||||||
|
|
||||||
const categoriesCall = mockSelect.mock.calls.find(([sql]) =>
|
|
||||||
(sql as string).includes("FROM categories"),
|
|
||||||
)!;
|
|
||||||
const budgetEntriesCall = mockSelect.mock.calls.find(([sql]) =>
|
|
||||||
(sql as string).includes("FROM budget_entries"),
|
|
||||||
)!;
|
|
||||||
expect(categoriesCall[0]).not.toContain("source_id");
|
|
||||||
expect(budgetEntriesCall[0]).not.toContain("source_id");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// getActualTotalsForYear is the previous-year actuals reference column
|
|
||||||
// consumed by useBudget (Issue #276) — a thin wrapper over the same
|
|
||||||
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.
|
|
||||||
describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => {
|
|
||||||
it("without accountIds, the query carries no source_id clause (regression)", async () => {
|
|
||||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
|
||||||
|
|
||||||
await getActualTotalsForYear(2025);
|
|
||||||
|
|
||||||
expect(mockSelect).toHaveBeenCalledTimes(1);
|
|
||||||
const [sql, params] = mockSelect.mock.calls[0];
|
|
||||||
expect(sql as string).not.toContain("source_id");
|
|
||||||
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
|
|
||||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
|
||||||
|
|
||||||
await getActualTotalsForYear(2025, []);
|
|
||||||
|
|
||||||
const [sql, params] = mockSelect.mock.calls[0];
|
|
||||||
expect(sql as string).not.toContain("source_id");
|
|
||||||
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies a parameterized accountIds IN clause", async () => {
|
|
||||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
|
||||||
|
|
||||||
await getActualTotalsForYear(2025, [4, 9]);
|
|
||||||
|
|
||||||
const [sql, params] = mockSelect.mock.calls[0];
|
|
||||||
expect(sql as string).toContain("source_id IN ($3, $4)");
|
|
||||||
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { inPlaceholders } from "../utils/sqlFilters";
|
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
BudgetEntry,
|
BudgetEntry,
|
||||||
|
|
@ -182,49 +181,34 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
||||||
// --- Actuals helpers ---
|
// --- Actuals helpers ---
|
||||||
|
|
||||||
export async function getActualTotalsForYear(
|
export async function getActualTotalsForYear(
|
||||||
year: number,
|
year: number
|
||||||
accountIds?: number[]
|
|
||||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||||
const dateFrom = `${year}-01-01`;
|
const dateFrom = `${year}-01-01`;
|
||||||
const dateTo = `${year}-12-31`;
|
const dateTo = `${year}-12-31`;
|
||||||
return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
|
return getActualsByCategoryRange(dateFrom, dateTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Budget vs Actual ---
|
// --- Budget vs Actual ---
|
||||||
|
|
||||||
async function getActualsByCategoryRange(
|
async function getActualsByCategoryRange(
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
dateTo: string,
|
dateTo: string
|
||||||
accountIds?: number[]
|
|
||||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const params: unknown[] = [dateFrom, dateTo];
|
|
||||||
const accountPlaceholders = inPlaceholders(accountIds, params.length + 1);
|
|
||||||
const accountFilter = accountPlaceholders ? ` AND source_id IN (${accountPlaceholders})` : "";
|
|
||||||
if (accountPlaceholders) params.push(...accountIds!);
|
|
||||||
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
||||||
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE date BETWEEN $1 AND $2${accountFilter}
|
WHERE date BETWEEN $1 AND $2
|
||||||
GROUP BY category_id`,
|
GROUP BY category_id`,
|
||||||
params
|
[dateFrom, dateTo]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Income-statement reading order: revenue first, then expenses, then
|
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||||
// transfers (Issue #277 — matches COMPARE_TYPE_ORDER / OVER_TIME_TYPE_ORDER).
|
|
||||||
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
|
|
||||||
* (Issue #273) optionally scopes both the month and YTD actuals to a subset of
|
|
||||||
* import sources (`transactions.source_id`) — budgeted amounts are unaffected
|
|
||||||
* by the filter (a budget is not tied to any account).
|
|
||||||
*/
|
|
||||||
export async function getBudgetVsActualData(
|
export async function getBudgetVsActualData(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number
|
||||||
accountIds?: number[]
|
|
||||||
): Promise<BudgetVsActualRow[]> {
|
): Promise<BudgetVsActualRow[]> {
|
||||||
// Date ranges
|
// Date ranges
|
||||||
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
||||||
|
|
@ -235,8 +219,8 @@ export async function getBudgetVsActualData(
|
||||||
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
||||||
getAllActiveCategories(),
|
getAllActiveCategories(),
|
||||||
getBudgetEntriesForYear(year),
|
getBudgetEntriesForYear(year),
|
||||||
getActualsByCategoryRange(monthFrom, monthTo, accountIds),
|
getActualsByCategoryRange(monthFrom, monthTo),
|
||||||
getActualsByCategoryRange(ytdFrom, ytdTo, accountIds),
|
getActualsByCategoryRange(ytdFrom, ytdTo),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build maps
|
// Build maps
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,9 @@ import {
|
||||||
KEYWORD_MAX_LENGTH,
|
KEYWORD_MAX_LENGTH,
|
||||||
} from "./categorizationService";
|
} from "./categorizationService";
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => ({
|
||||||
const getDb = vi.fn();
|
getDb: vi.fn(),
|
||||||
return {
|
}));
|
||||||
getDb,
|
|
||||||
// Tests don't exercise the real lock; withTransaction just runs the
|
|
||||||
// callback with whatever db the test wired getDb to resolve, so every
|
|
||||||
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
|
|
||||||
// service issues them on the mock db inside the callback).
|
|
||||||
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { getDb, withTransaction } from "./db";
|
import { getDb } from "./db";
|
||||||
import type Database from "@tauri-apps/plugin-sql";
|
|
||||||
import type { Keyword, RecentTransaction } from "../shared/types";
|
import type { Keyword, RecentTransaction } from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -223,16 +222,7 @@ export async function applyKeywordWithReassignment(
|
||||||
}
|
}
|
||||||
const keyword = validation.value;
|
const keyword = validation.value;
|
||||||
|
|
||||||
return withTransaction((db) =>
|
const db = await getDb();
|
||||||
applyKeywordInTransaction(db, input, keyword),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyKeywordInTransaction(
|
|
||||||
db: Database,
|
|
||||||
input: ApplyKeywordInput,
|
|
||||||
keyword: string,
|
|
||||||
): Promise<ApplyKeywordResult> {
|
|
||||||
await db.execute("BEGIN");
|
await db.execute("BEGIN");
|
||||||
try {
|
try {
|
||||||
// Is there already a row for this keyword spelling?
|
// Is there already a row for this keyword spelling?
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
// `getDb` is stubbed with a fake DB that captures every SQL statement issued
|
// `getDb` is stubbed with a fake DB that captures every SQL statement issued
|
||||||
// during applyMigration. This is a unit test — we do NOT run a real SQLite.
|
// during applyMigration. This is a unit test — we do NOT run a real SQLite.
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => ({
|
||||||
const getDb = vi.fn();
|
getDb: vi.fn(),
|
||||||
return {
|
}));
|
||||||
getDb,
|
|
||||||
// Tests don't exercise the real lock; withTransaction just runs the
|
|
||||||
// callback with whatever db the test wired getDb to resolve, so every
|
|
||||||
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
|
|
||||||
// service issues them on the mock db inside the callback).
|
|
||||||
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// `setPreference` goes through the same fake DB — no extra stubbing needed
|
// `setPreference` goes through the same fake DB — no extra stubbing needed
|
||||||
// because userPreferenceService itself calls getDb().
|
// because userPreferenceService itself calls getDb().
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { withTransaction } from "./db";
|
import { getDb } from "./db";
|
||||||
import type Database from "@tauri-apps/plugin-sql";
|
|
||||||
import { setPreference } from "./userPreferenceService";
|
import { setPreference } from "./userPreferenceService";
|
||||||
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
|
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
|
||||||
import type { BackupResult } from "./categoryBackupService";
|
import type { BackupResult } from "./categoryBackupService";
|
||||||
|
|
@ -179,17 +178,7 @@ export async function applyMigration(
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
return withTransaction((db) =>
|
const db = await getDb();
|
||||||
applyMigrationInTransaction(db, plan, backup, outcome),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyMigrationInTransaction(
|
|
||||||
db: Database,
|
|
||||||
plan: MigrationPlan,
|
|
||||||
backup: BackupResult,
|
|
||||||
outcome: MigrationOutcome,
|
|
||||||
): Promise<MigrationOutcome> {
|
|
||||||
const mapping = buildMappingFromRows(plan.rows);
|
const mapping = buildMappingFromRows(plan.rows);
|
||||||
|
|
||||||
await db.execute("BEGIN");
|
await db.execute("BEGIN");
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { getExpensesByCategory, deriveNetWorthTile } from "./dashboardService";
|
|
||||||
import type { SnapshotTotalPoint } from "./balance.service";
|
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
|
||||||
const getDb = vi.fn();
|
|
||||||
return { getDb };
|
|
||||||
});
|
|
||||||
|
|
||||||
import { getDb } from "./db";
|
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
|
||||||
const mockDb = { select: mockSelect };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
|
||||||
mockSelect.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getExpensesByCategory — accountIds filter (Issue #273)", () => {
|
|
||||||
it("without accountIds, the query carries no source_id clause (regression)", async () => {
|
|
||||||
mockSelect.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await getExpensesByCategory("2025-01-01", "2025-12-31");
|
|
||||||
|
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
|
||||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
||||||
expect(sql).not.toContain("source_id");
|
|
||||||
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
|
|
||||||
mockSelect.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await getExpensesByCategory("2025-01-01", "2025-12-31", []);
|
|
||||||
|
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
|
||||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
||||||
expect(sql).not.toContain("source_id");
|
|
||||||
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
|
|
||||||
mockSelect.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await getExpensesByCategory("2025-01-01", "2025-12-31", [6, 11]);
|
|
||||||
|
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
|
||||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
||||||
expect(sql).toContain("t.source_id IN ($3, $4)");
|
|
||||||
expect(sql).not.toContain("6,11");
|
|
||||||
expect(params).toEqual(["2025-01-01", "2025-12-31", 6, 11]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the accountIds filter alone (no dates) alongside the fixed expense-type clause", async () => {
|
|
||||||
mockSelect.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await getExpensesByCategory(undefined, undefined, [2]);
|
|
||||||
|
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
|
||||||
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
||||||
expect(sql).toContain("t.source_id IN ($1)");
|
|
||||||
expect(sql).toContain("COALESCE(c.type, 'expense') = 'expense'");
|
|
||||||
expect(params).toEqual([2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deriveNetWorthTile (Issue #279)", () => {
|
|
||||||
const totals: SnapshotTotalPoint[] = [
|
|
||||||
{ snapshot_date: "2026-01-31", total: 1000 },
|
|
||||||
{ snapshot_date: "2026-02-28", total: 1234.56 },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("is hidden when there is no balance account at all", () => {
|
|
||||||
expect(deriveNetWorthTile(0, false, totals)).toEqual({
|
|
||||||
visible: false,
|
|
||||||
total: null,
|
|
||||||
asOfDate: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is hidden when accounts exist but no snapshot has ever been recorded (never a misleading $0)", () => {
|
|
||||||
expect(deriveNetWorthTile(3, false, [])).toEqual({
|
|
||||||
visible: false,
|
|
||||||
total: null,
|
|
||||||
asOfDate: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is hidden when accounts exist but no snapshot has ever been recorded, even if totals is non-empty (defensive)", () => {
|
|
||||||
expect(deriveNetWorthTile(3, false, totals)).toEqual({
|
|
||||||
visible: false,
|
|
||||||
total: null,
|
|
||||||
asOfDate: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is visible with the latest (last) point once accounts and a snapshot both exist", () => {
|
|
||||||
expect(deriveNetWorthTile(3, true, totals)).toEqual({
|
|
||||||
visible: true,
|
|
||||||
total: 1234.56,
|
|
||||||
asOfDate: "2026-02-28",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays hidden if the two signals disagree with an empty totals series (defensive guard)", () => {
|
|
||||||
expect(deriveNetWorthTile(3, true, [])).toEqual({
|
|
||||||
visible: false,
|
|
||||||
total: null,
|
|
||||||
asOfDate: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { inPlaceholders } from "../utils/sqlFilters";
|
|
||||||
import { deriveLandingState } from "../components/balance/balanceLanding";
|
|
||||||
import type { SnapshotTotalPoint } from "./balance.service";
|
|
||||||
import type {
|
import type {
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
|
|
@ -57,7 +54,7 @@ export async function getDashboardSummary(
|
||||||
export async function getExpensesByCategory(
|
export async function getExpensesByCategory(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
accountIds?: number[],
|
sourceId?: number,
|
||||||
): Promise<CategoryBreakdownItem[]> {
|
): Promise<CategoryBreakdownItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -75,11 +72,10 @@ export async function getExpensesByCategory(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
if (sourceId != null) {
|
||||||
if (accountPlaceholders) {
|
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||||
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
|
params.push(sourceId);
|
||||||
params.push(...accountIds!);
|
paramIndex++;
|
||||||
paramIndex += accountIds!.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
@ -166,47 +162,3 @@ export async function getRecentTransactions(
|
||||||
[limit]
|
[limit]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Net-worth tile (Issue #279) ---
|
|
||||||
//
|
|
||||||
// The Dashboard's net-worth tile surfaces the Bilan's latest total
|
|
||||||
// (`getSnapshotTotalsByDate`, balance.service.ts:1767) — a DIFFERENT metric
|
|
||||||
// from every other Dashboard widget: it is the latest snapshot of account
|
|
||||||
// balances, not a transaction P&L. It must never be confused with (or scoped
|
|
||||||
// by) the transactional account/import-source filter, and must never render
|
|
||||||
// a misleading "$0" before any snapshot has ever been recorded.
|
|
||||||
|
|
||||||
export interface NetWorthTileData {
|
|
||||||
visible: boolean;
|
|
||||||
total: number | null;
|
|
||||||
asOfDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_NET_WORTH_TILE: NetWorthTileData = {
|
|
||||||
visible: false,
|
|
||||||
total: null,
|
|
||||||
asOfDate: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shapes the net-worth tile from the same two signals BalancePage's landing
|
|
||||||
* state already uses (`deriveLandingState`) — reused here rather than
|
|
||||||
* re-inferring "no data" from nulls, per the #279 review caveat. The tile
|
|
||||||
* stays hidden (never a misleading "$0") unless at least one balance account
|
|
||||||
* exists AND at least one snapshot has ever been recorded; `totals` (the
|
|
||||||
* `getSnapshotTotalsByDate` series, ascending by date) is otherwise expected
|
|
||||||
* to carry at least one point, but a defensive check guards a theoretical
|
|
||||||
* mismatch between the two independent queries.
|
|
||||||
*/
|
|
||||||
export function deriveNetWorthTile(
|
|
||||||
accountsCount: number,
|
|
||||||
hasAnySnapshot: boolean,
|
|
||||||
totals: SnapshotTotalPoint[]
|
|
||||||
): NetWorthTileData {
|
|
||||||
if (deriveLandingState(accountsCount, hasAnySnapshot) !== "populated") {
|
|
||||||
return EMPTY_NET_WORTH_TILE;
|
|
||||||
}
|
|
||||||
const latest = totals[totals.length - 1];
|
|
||||||
if (!latest) return EMPTY_NET_WORTH_TILE;
|
|
||||||
return { visible: true, total: latest.total, asOfDate: latest.snapshot_date };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
// Regression coverage for the "database is locked" fix (#228 follow-up).
|
|
||||||
//
|
|
||||||
// tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
|
|
||||||
// and exposes no transaction primitive, so a BEGIN and its COMMIT issued as
|
|
||||||
// separate db.execute calls could land on different pooled connections and
|
|
||||||
// strand an open write transaction (= permanent lock). db.ts funnels every
|
|
||||||
// operation through one FIFO lock, and withTransaction holds it for the whole
|
|
||||||
// BEGIN..COMMIT. These tests assert that serialization, without a real SQLite.
|
|
||||||
|
|
||||||
// Ordered log of every fake-db operation's start/end, shared with the fake db.
|
|
||||||
const events: string[] = [];
|
|
||||||
// Hoisted handle so the (hoisted) vi.mock factory can reach the per-test db.
|
|
||||||
const h = vi.hoisted(() => ({ fakeDb: null as unknown as FakeDb }));
|
|
||||||
|
|
||||||
interface FakeDb {
|
|
||||||
execute: (q: string, p?: unknown[]) => Promise<{ rowsAffected: number }>;
|
|
||||||
select: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
|
||||||
close: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/plugin-sql", () => ({
|
|
||||||
default: { load: vi.fn(async () => h.fakeDb) },
|
|
||||||
}));
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => false) }));
|
|
||||||
|
|
||||||
import { connectToProfile, getDb, withTransaction } from "./db";
|
|
||||||
|
|
||||||
// Each op records start, yields a real macrotask (a window for interleaving if
|
|
||||||
// the lock were broken), then records end. The lock must keep them ordered.
|
|
||||||
function makeFakeDb(): FakeDb {
|
|
||||||
return {
|
|
||||||
execute: vi.fn(async (q: string) => {
|
|
||||||
events.push(`start:${q}`);
|
|
||||||
await new Promise((r) => setTimeout(r, 2));
|
|
||||||
events.push(`end:${q}`);
|
|
||||||
return { rowsAffected: 0 };
|
|
||||||
}),
|
|
||||||
select: vi.fn(async (q: string) => {
|
|
||||||
events.push(`start:${q}`);
|
|
||||||
await new Promise((r) => setTimeout(r, 2));
|
|
||||||
events.push(`end:${q}`);
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
close: vi.fn(async () => {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
events.length = 0;
|
|
||||||
h.fakeDb = makeFakeDb();
|
|
||||||
await connectToProfile("test.db");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("db serialization (database-locked fix)", () => {
|
|
||||||
it("serializes concurrent execute calls — no interleaving", async () => {
|
|
||||||
const db = await getDb();
|
|
||||||
// Fire both without awaiting between them: they race for the lock.
|
|
||||||
await Promise.all([db.execute("A"), db.execute("B")]);
|
|
||||||
// A must fully complete before B starts (FIFO, no overlap).
|
|
||||||
expect(events).toEqual(["start:A", "end:A", "start:B", "end:B"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("holds the lock across a whole withTransaction — an outside read cannot interleave", async () => {
|
|
||||||
const db = await getDb();
|
|
||||||
// Queue the transaction first, then an outside read.
|
|
||||||
const txn = withTransaction(async (tx) => {
|
|
||||||
await tx.execute("BEGIN");
|
|
||||||
await tx.execute("WRITE");
|
|
||||||
await tx.execute("COMMIT");
|
|
||||||
});
|
|
||||||
const outside = db.select("READ");
|
|
||||||
await Promise.all([txn, outside]);
|
|
||||||
// The outside read runs entirely AFTER the transaction's COMMIT — the
|
|
||||||
// pre-fix bug was exactly an outside read interleaving mid-transaction.
|
|
||||||
expect(events).toEqual([
|
|
||||||
"start:BEGIN",
|
|
||||||
"end:BEGIN",
|
|
||||||
"start:WRITE",
|
|
||||||
"end:WRITE",
|
|
||||||
"start:COMMIT",
|
|
||||||
"end:COMMIT",
|
|
||||||
"start:READ",
|
|
||||||
"end:READ",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets a nested getDb() call run directly inside a transaction — no deadlock", async () => {
|
|
||||||
// A service called inside withTransaction might reach for getDb() (the
|
|
||||||
// serialized proxy) instead of the threaded handle. The reentrancy guard
|
|
||||||
// must run it directly rather than queue it behind the held lock (deadlock).
|
|
||||||
const result = await withTransaction(async () => {
|
|
||||||
const inner = await getDb();
|
|
||||||
await inner.execute("NESTED");
|
|
||||||
return "ok";
|
|
||||||
});
|
|
||||||
expect(result).toBe("ok");
|
|
||||||
expect(events).toEqual(["start:NESTED", "end:NESTED"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("releases the lock after a failed transaction so later ops still run", async () => {
|
|
||||||
const db = await getDb();
|
|
||||||
await expect(
|
|
||||||
withTransaction(async (tx) => {
|
|
||||||
await tx.execute("BEGIN");
|
|
||||||
throw new Error("boom");
|
|
||||||
})
|
|
||||||
).rejects.toThrow("boom");
|
|
||||||
// The lock must be free again: a subsequent op completes (no stuck queue).
|
|
||||||
await db.execute("AFTER");
|
|
||||||
expect(events).toContain("start:AFTER");
|
|
||||||
expect(events).toContain("end:AFTER");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("withTransaction rejects when no profile is connected", async () => {
|
|
||||||
const { closeDb } = await import("./db");
|
|
||||||
await closeDb();
|
|
||||||
await expect(withTransaction(async () => undefined)).rejects.toThrow(
|
|
||||||
/No database connection/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,113 +1,19 @@
|
||||||
import Database from "@tauri-apps/plugin-sql";
|
import Database from "@tauri-apps/plugin-sql";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
let rawInstance: Database | null = null;
|
let dbInstance: Database | null = null;
|
||||||
let serializedInstance: Database | null = null;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DB access serialization (#228 follow-up — fix "database is locked").
|
|
||||||
//
|
|
||||||
// tauri-plugin-sql exposes NO transaction primitive to the JS side and loads
|
|
||||||
// SQLite through a default multi-connection sqlx pool (`Pool::connect` =>
|
|
||||||
// max_connections = 10). Every db.execute / db.select is an independent
|
|
||||||
// `pool.acquire()`, so a `BEGIN` issued on one pooled connection and its
|
|
||||||
// matching `COMMIT` issued on another would strand an open write transaction
|
|
||||||
// on an idle connection — that connection then holds SQLite's write lock until
|
|
||||||
// the app restarts, and every later write fails with "database is locked".
|
|
||||||
//
|
|
||||||
// Fix: funnel EVERY db operation through a single FIFO async lock. Plain
|
|
||||||
// reads/writes take the lock per-statement; `withTransaction` holds it across
|
|
||||||
// the whole BEGIN..COMMIT. With no two operations ever overlapping in time,
|
|
||||||
// the pool keeps handing back its single idle connection, so a transaction's
|
|
||||||
// statements all run on the same connection and can never strand. Serializing
|
|
||||||
// all DB access is a non-issue for a local single-user app where queries are
|
|
||||||
// tiny and infrequent — and it makes the "atomic" saves genuinely atomic.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let lockTail: Promise<unknown> = Promise.resolve();
|
|
||||||
|
|
||||||
// True only while a `withTransaction` callback is executing. The lock is held
|
|
||||||
// for that whole callback, so anything it triggers (the threaded handle, or an
|
|
||||||
// accidental `getDb()` call from a nested service) must run DIRECTLY rather
|
|
||||||
// than re-entering the non-reentrant lock — otherwise it would deadlock behind
|
|
||||||
// the transaction that is waiting for it.
|
|
||||||
let inTransaction = false;
|
|
||||||
|
|
||||||
/** Run `fn` after every previously-queued DB operation has settled (FIFO). */
|
|
||||||
function runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
const result = lockTail.then(fn, fn);
|
|
||||||
// Keep the chain alive regardless of fn's outcome; only the tail swallows.
|
|
||||||
lockTail = result.then(
|
|
||||||
() => undefined,
|
|
||||||
() => undefined
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap the raw Database so `execute`/`select` are serialized through the lock.
|
|
||||||
* While a transaction holds the lock (`inTransaction`), calls run directly so a
|
|
||||||
* nested call never deadlocks. The proxy keeps the `Database` type, so call
|
|
||||||
* sites (including `db.select<T>(...)`) are unchanged.
|
|
||||||
*/
|
|
||||||
function serialize(db: Database): Database {
|
|
||||||
return new Proxy(db, {
|
|
||||||
get(target, prop, receiver) {
|
|
||||||
if (prop === "execute") {
|
|
||||||
return (query: string, bindValues?: unknown[]) =>
|
|
||||||
inTransaction
|
|
||||||
? target.execute(query, bindValues)
|
|
||||||
: runExclusive(() => target.execute(query, bindValues));
|
|
||||||
}
|
|
||||||
if (prop === "select") {
|
|
||||||
return (query: string, bindValues?: unknown[]) =>
|
|
||||||
inTransaction
|
|
||||||
? target.select(query, bindValues)
|
|
||||||
: runExclusive(() => target.select(query, bindValues));
|
|
||||||
}
|
|
||||||
const value = Reflect.get(target, prop, receiver);
|
|
||||||
return typeof value === "function" ? value.bind(target) : value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDb(): Promise<Database> {
|
export async function getDb(): Promise<Database> {
|
||||||
if (!serializedInstance) {
|
if (!dbInstance) {
|
||||||
throw new Error("No database connection. Call connectToProfile() first.");
|
throw new Error("No database connection. Call connectToProfile() first.");
|
||||||
}
|
}
|
||||||
return serializedInstance;
|
return dbInstance;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run `fn` as one serialized transaction. The global DB lock is held for the
|
|
||||||
* ENTIRE callback so no other db.execute/select can interleave and strand the
|
|
||||||
* transaction on a different pooled connection. `fn` receives the db handle and
|
|
||||||
* is responsible for issuing BEGIN/COMMIT (and ROLLBACK on error), exactly as
|
|
||||||
* the inline pattern did before — `withTransaction` only guarantees exclusive,
|
|
||||||
* non-interleaved access for the whole BEGIN..COMMIT.
|
|
||||||
*/
|
|
||||||
export async function withTransaction<T>(
|
|
||||||
fn: (db: Database) => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
if (!serializedInstance) {
|
|
||||||
throw new Error("No database connection. Call connectToProfile() first.");
|
|
||||||
}
|
|
||||||
const db = serializedInstance;
|
|
||||||
return runExclusive(async () => {
|
|
||||||
inTransaction = true;
|
|
||||||
try {
|
|
||||||
return await fn(db);
|
|
||||||
} finally {
|
|
||||||
inTransaction = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectToProfile(dbFilename: string): Promise<void> {
|
export async function connectToProfile(dbFilename: string): Promise<void> {
|
||||||
if (rawInstance) {
|
if (dbInstance) {
|
||||||
await rawInstance.close();
|
await dbInstance.close();
|
||||||
rawInstance = null;
|
dbInstance = null;
|
||||||
serializedInstance = null;
|
|
||||||
}
|
}
|
||||||
// Repair migration checksums before loading (fixes "migration was modified" error)
|
// Repair migration checksums before loading (fixes "migration was modified" error)
|
||||||
try {
|
try {
|
||||||
|
|
@ -118,27 +24,23 @@ export async function connectToProfile(dbFilename: string): Promise<void> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Migration repair failed:", e);
|
console.error("Migration repair failed:", e);
|
||||||
}
|
}
|
||||||
rawInstance = await Database.load(`sqlite:${dbFilename}`);
|
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
||||||
serializedInstance = serialize(rawInstance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
|
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
|
||||||
if (rawInstance) {
|
if (dbInstance) {
|
||||||
await rawInstance.close();
|
await dbInstance.close();
|
||||||
rawInstance = null;
|
dbInstance = null;
|
||||||
serializedInstance = null;
|
|
||||||
}
|
}
|
||||||
rawInstance = await Database.load(`sqlite:${dbFilename}`);
|
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
||||||
serializedInstance = serialize(rawInstance);
|
|
||||||
for (const sql of sqlStatements) {
|
for (const sql of sqlStatements) {
|
||||||
await rawInstance.execute(sql);
|
await dbInstance.execute(sql);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeDb(): Promise<void> {
|
export async function closeDb(): Promise<void> {
|
||||||
if (rawInstance) {
|
if (dbInstance) {
|
||||||
await rawInstance.close();
|
await dbInstance.close();
|
||||||
rawInstance = null;
|
dbInstance = null;
|
||||||
serializedInstance = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
|
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
|
||||||
import {
|
import { getRecentErrorLogs, clearLogs, initLogCapture } from "./logService";
|
||||||
getRecentErrorLogs,
|
|
||||||
getLogs,
|
|
||||||
clearLogs,
|
|
||||||
initLogCapture,
|
|
||||||
} from "./logService";
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// Patch console.* so addEntry runs. Idempotent.
|
// Patch console.* so addEntry runs. Idempotent.
|
||||||
|
|
@ -59,39 +54,3 @@ describe("getRecentErrorLogs", () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression: the log console (Settings → Systems → Journaux) stayed empty
|
|
||||||
// because getLogs returned the same mutated array reference on every call, so
|
|
||||||
// useSyncExternalStore (which compares snapshots by identity) never re-rendered
|
|
||||||
// when a new log arrived. getLogs must return a STABLE reference between
|
|
||||||
// mutations (no per-call re-slice, or React loops) and a NEW one after each.
|
|
||||||
describe("getLogs — live-update snapshot", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearLogs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a stable reference between mutations", () => {
|
|
||||||
expect(getLogs()).toBe(getLogs());
|
|
||||||
console.warn("x");
|
|
||||||
const snap = getLogs();
|
|
||||||
expect(snap).toBe(getLogs());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a NEW reference after a log so the store re-renders", () => {
|
|
||||||
const before = getLogs();
|
|
||||||
console.warn("new entry");
|
|
||||||
const after = getLogs();
|
|
||||||
expect(after).not.toBe(before);
|
|
||||||
expect(after).toHaveLength(before.length + 1);
|
|
||||||
expect(after[after.length - 1].message).toBe("new entry");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a NEW reference after clearLogs", () => {
|
|
||||||
console.warn("y");
|
|
||||||
const before = getLogs();
|
|
||||||
clearLogs();
|
|
||||||
const after = getLogs();
|
|
||||||
expect(after).not.toBe(before);
|
|
||||||
expect(after).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,6 @@ const STORAGE_KEY = "simpl-resultat-logs";
|
||||||
const logs: LogEntry[] = [];
|
const logs: LogEntry[] = [];
|
||||||
const listeners = new Set<LogListener>();
|
const listeners = new Set<LogListener>();
|
||||||
|
|
||||||
// Immutable view of `logs`, rebuilt on every mutation. `useSyncExternalStore`
|
|
||||||
// compares snapshots by identity (Object.is), so `getLogs` MUST return a NEW
|
|
||||||
// reference whenever the logs change — and a STABLE one between changes (never
|
|
||||||
// re-slice per call, which would loop the renderer). The old behaviour mutated
|
|
||||||
// `logs` in place and returned that same array, so the log console never
|
|
||||||
// updated live: a log produced while the panel was open was never re-rendered.
|
|
||||||
let logsSnapshot: readonly LogEntry[] = [];
|
|
||||||
|
|
||||||
function refreshSnapshot() {
|
|
||||||
logsSnapshot = logs.slice();
|
|
||||||
}
|
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
function loadFromStorage() {
|
function loadFromStorage() {
|
||||||
|
|
@ -33,7 +21,6 @@ function loadFromStorage() {
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed: LogEntry[] = JSON.parse(stored);
|
const parsed: LogEntry[] = JSON.parse(stored);
|
||||||
logs.push(...parsed);
|
logs.push(...parsed);
|
||||||
refreshSnapshot();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore corrupted storage
|
// ignore corrupted storage
|
||||||
|
|
@ -64,7 +51,6 @@ function addEntry(level: LogLevel, args: unknown[]) {
|
||||||
if (logs.length > MAX_ENTRIES) {
|
if (logs.length > MAX_ENTRIES) {
|
||||||
logs.splice(0, logs.length - MAX_ENTRIES);
|
logs.splice(0, logs.length - MAX_ENTRIES);
|
||||||
}
|
}
|
||||||
refreshSnapshot();
|
|
||||||
|
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
listeners.forEach((fn) => fn());
|
listeners.forEach((fn) => fn());
|
||||||
|
|
@ -97,7 +83,7 @@ export function initLogCapture() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLogs(): readonly LogEntry[] {
|
export function getLogs(): readonly LogEntry[] {
|
||||||
return logsSnapshot;
|
return logs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the N most recent non-info entries formatted as a single string,
|
/// Extract the N most recent non-info entries formatted as a single string,
|
||||||
|
|
@ -117,30 +103,10 @@ export function getRecentErrorLogs(n: number): string {
|
||||||
|
|
||||||
export function clearLogs() {
|
export function clearLogs() {
|
||||||
logs.length = 0;
|
logs.length = 0;
|
||||||
refreshSnapshot();
|
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
listeners.forEach((fn) => fn());
|
listeners.forEach((fn) => fn());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// App logging API. Prefer these over raw `console.*` in application code so
|
|
||||||
// meaningful events land in the in-app log console (Settings → Systems →
|
|
||||||
// Journaux / Logs). They forward to the captured console methods, so entries
|
|
||||||
// also show up in devtools. `console.log` is captured as the `info` level.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function logInfo(...args: unknown[]): void {
|
|
||||||
console.log(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logWarn(...args: unknown[]): void {
|
|
||||||
console.warn(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logError(...args: unknown[]): void {
|
|
||||||
console.error(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribe(listener: LogListener): () => void {
|
export function subscribe(listener: LogListener): () => void {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => listeners.delete(listener);
|
return () => listeners.delete(listener);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue