Simpl-Resultat/spec-refonte-rapports.md
le king fu 4912ae39b0 docs: add WIP specs for OAuth keychain, monetisation, reports, and web
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:41:00 -04:00

48 KiB
Raw Blame History

Spec — Refonte complete des rapports

Date : 2026-04-13 Projet : simpl-resultat Statut : Revised post-review (2026-04-13) — prêt pour implémentation

Contexte

La page /reports actuelle expose cinq onglets (trends, byCategory, overTime, budgetVsActual, dynamic) pensés comme autant de vues analytiques indépendantes. L'ensemble souffre de trois limites :

  1. Pas de récit — aucune vue ne répond à la question « qu'est-ce qui est important à savoir sur mes finances ce mois-ci ? ». L'utilisateur doit naviguer entre les onglets et reconstituer le tableau d'ensemble lui-même.
  2. Pivot surdimensionné — le tableau croisé dynamique (DynamicReport) est puissant mais complexe, peu utilisé dans la pratique, et ajoute une dette visuelle/cognitive. Son usage se résume en réalité à « zoomer sur une catégorie ».
  3. Classification déconnectée — les mots-clés s'éditent uniquement depuis /categories. Quand l'utilisateur voit une transaction mal classée dans un rapport, il doit quitter son contexte pour aller modifier la règle puis revenir.

La refonte garde la signature visuelle (palette couleurs catégorie + patterns SVG grayscale-friendly) mais réorganise le contenu autour de quatre axes d'analyse correspondant à quatre questions utilisateur :

Rapport Question utilisateur
Faits saillants « Qu'est-ce qui a bougé ce mois ? »
Tendances « Où je vais sur les 12 derniers mois ? »
Comparables « Comment je me situe vs période précédente ou vs budget ? »
Analyse ponctuelle « Montre-moi tout sur cette catégorie. »

Objectif

Refondre /reports en un hub unifié qui présente un aperçu des faits saillants + quatre rapports dédiés (tendances, comparables, faits saillants, zoom catégorie), avec toggle graphique/tableau partout, signature visuelle conservée, et édition contextuelle des mots-clés (clic droit sur transaction → preview → appliquer) pour améliorer la classification sans quitter le rapport.

🟢 ARCHITECTURE — Hub + sous-routes est le bon move. URLs bookmarkables, back button natif, chaque page charge ses propres données, meilleur code-splitting possible. Resolution : Procéder, mais résoudre d'abord les 3 critiques architecture (structure pages plate, split useReports, mécanisme de partage de période) pour ne pas bâtir sur des bases désalignées.

Scope

IN

  • Nouvelle page hub /reports affichant en haut un panneau "Faits saillants" (top mouvements, top transactions récentes, solde net mois courant + YTD) suivi de quatre cartes menant aux quatre sous-rapports.
  • Quatre sous-pages :
    • /reports/highlights — version détaillée des faits saillants
    • /reports/trends — revenus vs dépenses (flux global) + évolution par catégorie
    • /reports/compare — mois vs mois-1, année vs année-1, réel vs budget (mois et année), navigation tabulaire
    • /reports/category — zoom sur une catégorie avec rollup automatique des sous-catégories
  • Toggle graphique ↔ tableau sur tous les sous-rapports, défaut graphique, préférence mémorisée par rapport dans localStorage.
  • Conservation de la signature visuelle : palette couleurs existante + patterns SVG (chartPatterns.tsx) + Recharts. Ajout de deux nouveaux types de charts : sparklines (pour les faits saillants) et donut chart (pour la répartition dans le zoom catégorie).
  • Rollup automatique des sous-catégories dans le zoom catégorie (sélectionner Alimentation inclut toutes ses enfants, avec sous-total par enfant). Toggle « direct seulement » disponible.
  • Édition contextuelle des mots-clés : clic droit sur n'importe quelle transaction (dans n'importe quel rapport ou dans la page Transactions) → menu « Ajouter ce libellé comme mot-clé pour catégorie X » → dialog preview des matches → Appliquer / Annuler.
  • Retrait du tableau croisé dynamique de l'UI : les composants DynamicReport* restent dans le code mais derrière un feature flag désactivé par défaut (ou route cachée /reports/_pivot), au cas où un usage avancé réémergerait.
  • Période par défaut à l'ouverture du hub : année civile en cours (1er janvier → 31 décembre).
  • Traductions FR/EN complètes pour toutes les nouvelles clés.
  • Mise à jour de CHANGELOG.md et CHANGELOG.fr.md sous ## [Unreleased].

OUT (explicitement exclu)

  • Projection / prévision des prochains mois (pas de ML, pas d'extrapolation).
  • Moyennes mobiles (mentionnées mais non retenues pour ce sprint).
  • Anomalies / alertes automatiques (pas de détection "dépense inhabituelle").
  • KPIs dérivés (taux d'épargne, ratio fixe/variable) — à reconsidérer plus tard.
  • Édition des mots-clés directement inline dans le panneau du zoom catégorie (on se limite au clic droit contextuel).
  • Migration de données : aucune nouvelle table SQL, aucun changement de schéma.
  • Remplacement de Recharts par une autre librairie.
  • Rapports par fournisseur / par tag / par compte bancaire (hors scope).

Design

UX / Interface

Hub /reports

┌─────────────────────────────────────────────────────────┐
│  Rapports                          [Période : 2026 ▾]  │
├─────────────────────────────────────────────────────────┤
│  FAITS SAILLANTS                                        │
│  ┌──────────┬──────────┬────────────┬──────────────┐    │
│  │ Solde    │ Solde    │ Top hausse │ Top baisse   │    │
│  │ avril    │ YTD      │ Restos     │ Épicerie     │    │
│  │ +312 $   │ +1 845 $ │ +240 $     │ -85 $        │    │
│  │ [spark]  │ [spark]  │ vs mars    │ vs mars      │    │
│  └──────────┴──────────┴────────────┴──────────────┘    │
│                                                         │
│  Top 5 transactions récentes                            │
│  • 2026-04-10  Loyer avril           1 450,00 $         │
│  • 2026-04-08  Remboursement prêt      680,00 $         │
│  • ...                                                  │
├─────────────────────────────────────────────────────────┤
│  EXPLORER                                               │
│  ┌─────────────┬─────────────┬─────────────┬──────────┐ │
│  │ Tendances   │ Comparables │ Faits saill.│ Analyse  │ │
│  │   📈        │    ⚖         │    ⭐        │   🔍     │ │
│  └─────────────┴─────────────┴─────────────┴──────────┘ │
└─────────────────────────────────────────────────────────┘
  • Le hub charge un ReportsHighlights résumé en haut, même données que /reports/highlights mais layout condensé.
  • Quatre tuiles de navigation au bas mènent aux sous-pages.
  • Sélecteur de période global en haut à droite, partagé avec les sous-rapports (contexte conservé à la navigation).

🔴 ARCHITECTURE — Mécanisme de partage de période non spécifié. Avec 4 routes séparées, chaque page monte un useReports neuf → l'état local est perdu à chaque navigation. « Contexte conservé » n'est pas défini. Resolution : Utiliser une query string ?from=...&to=...&period=2026 (simple, bookmarkable, pas de contexte global — cohérent avec le reste du projet qui n'utilise pas de contexte React pour l'état UI).

/reports/highlights

Version détaillée des faits saillants :

  • Bloc Soldes : grandes tuiles avec solde net mois courant + YTD, chacune avec un sparkline 12 mois.
  • Bloc Top mouvements : tableau triable des catégories avec la plus forte variation absolue ($) ou relative (%) vs mois précédent. Toggle $ / %.
  • Bloc Top transactions récentes : liste des 10 plus grosses transactions des 30 derniers jours (configurable 30/60/90 jours).
  • Toggle graphique/tableau s'applique aux tops (barres horizontales ou tableau).

Deux sous-vues accessibles par un mini-toggle interne :

  • Flux global — AreaChart revenus/dépenses/solde net sur la période (reprend MonthlyTrendsChart, maintenu tel quel visuellement). Version tableau : MonthlyTrendsTable.
  • Par catégorie — sélection multi-catégories + courbes d'évolution (adapte CategoryOverTimeChart). Version tableau : CategoryOverTimeTable.

Un seul sélecteur graphique/tableau en haut qui s'applique à la sous-vue affichée.

/reports/compare

Trois modes accessibles par un tab bar secondaire :

  • Mois vs mois précédent — tableau catégories × 2 colonnes + écart $ / % ; version graphique = diverging bar chart centré sur 0.
  • Année vs année précédente — même principe sur 12 mois vs 12 mois.
  • Réel vs budget — reprend la logique de BudgetVsActualTable existante ; toggle mensuel / annuel (YTD).

Navigation entre les trois modes conserve la période et les filtres.

/reports/category

Vue single-category :

  • En haut : combobox de sélection de catégorie + toggle « inclure sous-catégories » (activé par défaut).
  • Zone principale :
    • Donut chart de la répartition par sous-catégorie (ou pie si pas de rollup), couleurs de catégorie.
    • Chart d'évolution mensuelle de la catégorie sur la période (AreaChart).
    • Tableau des transactions de la catégorie (sortable, filtrable par date/montant).
  • Toggle graphique/tableau cache/montre les visualisations.

Édition contextuelle des mots-clés

Trigger : clic droit sur une ligne de transaction dans n'importe quel tableau (rapports, zoom catégorie, mais aussi éventuellement /transactions).

┌──────────────────────────────────────────┐
│ Ajouter le mot-clé « METRO » ?           │
│                                          │
│ Catégorie cible : [Alimentation ▾]       │
│ Priorité : [100    ]                     │
│                                          │
│ Ce mot-clé matchera aussi :              │
│  ☑ 2026-03-15  METRO #123      45,00 $  │
│  ☑ 2026-03-02  METRO PLUS      67,20 $  │
│  ☐ 2026-02-18  METROPOLITAIN   12,00 $  │
│                                          │
│ ⚠ 3 transactions seront recatégorisées  │
│                                          │
│           [Annuler]   [Appliquer]        │
└──────────────────────────────────────────┘

Implémentation arrêtée (post-review sécurité) :

  • Normalisation : utiliser normalizeDescription et buildKeywordRegex depuis categorizationService.ts — ces helpers sont actuellement privés, à exporter dans Issue #6.
  • Validation longueur : keyword obligatoire entre 2 et 64 caractères après .trim(), rejet whitespace-only. Prévient ReDoS (CWE-1333).
  • Preview via SQL paramétrée : SELECT ... FROM transactions WHERE description LIKE ?1 (jamais d'interpolation de chaîne), puis filtrage en mémoire avec le regex compilé par buildKeywordRegex. Prévient injection SQL (CWE-89).
  • Affichage limité à 50 matches ; au-delà, une checkbox explicite « Appliquer aussi aux N-50 transactions non affichées » s'affiche (off par défaut).
  • Appliquer = exécution dans une transaction SQL englobante (BEGIN; INSERT keywords; UPDATE transactions; COMMIT;) via tauri-plugin-sql, avec rollback + toast erreur en cas d'échec. Application uniquement aux lignes cochées visibles (sauf si l'utilisateur a explicitement coché l'option des N-50 non affichées).
  • Comportement « mot-clé déjà existant pour autre catégorie » : UPDATE keywords SET category_id=? WHERE keyword=? + re-run de la catégorisation uniquement sur les matches visibles cochés (jamais rétroactif sur l'historique complet).
  • Rendu XSS-safe : les descriptions de transaction sont rendues comme enfants React ({tx.description}) — jamais dangerouslySetInnerHTML. Troncature via CSS uniquement (CWE-79).
  • Annuler = aucune modification, dialog fermé.

🔴 TECHNIQUEnormalizeString n'existe pas dans categorizationService.ts. Le service n'expose que buildKeywordRegex ; il existe un normalizeDescription privé (non exporté). L'import référencé dans la spec est invalide. Resolution : Ajouter à Issue 5 une tâche « Exporter normalizeDescription et buildKeywordRegex depuis categorizationService.ts » et corriger le nom dans la spec.

🔴 SECURITE — Preview SQL doit être paramétrée, jamais interpoler le mot-clé. SQLite n'a pas d'opérateur regex natif ; une implémentation naïve construirait un LIKE '%' || keyword || '%' interpolé à partir d'un texte de transaction potentiellement malveillant (CSV importé), ouvrant une injection SQL locale. Resolution : Charger les candidats via SQL paramétré (LIKE ?1 ou scan complet via tauri-plugin-sql binding) puis filtrer en mémoire avec le regex compilé par buildKeywordRegex. Jamais de string || keyword. Ref : OWASP A03:2021 / CWE-89

🔴 SECURITE — ReDoS / absence de cap sur la longueur du mot-clé. buildKeywordRegex échappe les metacharactères mais ne limite pas la longueur. Un mot-clé de 5000 caractères serait compilé puis rejoué à chaque import — freeze garanti de l'app. Resolution : Dans AddKeywordDialog, valider keyword.trim().length entre 2 et 64 avant INSERT ; rejeter whitespace-only. Ajouter cette règle à la table Edge cases. Ref : CWE-1333

🟡 SECURITE — L'apply doit tourner en une seule transaction SQL (BEGIN/COMMIT). Le critère d'acceptation le mentionne mais la narration décrit INSERT + UPDATE séquentiels. Un crash entre les deux laisse un keyword orphelin ou des transactions non-recatégorisées — sur une app privacy-first sans backup par défaut, la confiance est ébranlée. Resolution : Envelopper explicitement INSERT keywords + UPDATE transactions dans BEGIN / COMMIT / ROLLBACK via tauri-plugin-sql ; surfacer les erreurs de rollback via un toast. Ref : CWE-662

🟡 SECURITE — « Preview 50 + apply sur tous » viole le contrôle utilisateur. La Edge case dit « Dialog limite l'affichage à 50 matches avec + N autres ; l'UPDATE s'applique bien à tous ». L'utilisateur coche 50 lignes, l'app en modifie 300 sans undo. Resolution : Soit appliquer uniquement aux lignes cochées réellement affichées, soit exiger une confirmation explicite « Appliquer aussi aux N-50 transactions non affichées » (checkbox off par défaut). Ref : OWASP ASVS V1.11.2

🟡 SECURITE — « Remplacer » un mot-clé existant n'est pas défini. La edge case dit « Ce mot-clé existe déjà pour catégorie X, remplacer ? » mais ne précise pas si ça UPDATE silencieusement l'ancien keyword (ce qui re-catégoriserait rétroactivement des années de transactions) ou crée un doublon. Resolution : Décider explicitement : UPDATE keywords SET category_id=? WHERE keyword=? + re-run de la catégorisation uniquement sur les matches visibles (pas sur l'historique). Écrire la décision dans la spec.

🟡 ARCHITECTUREAddKeywordDialog et TransactionContextMenu ne sont pas du domaine "reports". La spec dit explicitement qu'ils seront utilisés dans /transactions et les rapports. Les placer sous components/reports/shared/ viole SRP. Resolution : Placer AddKeywordDialog dans components/categories/ (domaine édition mot-clé) et TransactionContextMenu dans components/shared/ (cross-page).

🟢 SECURITE — Rendre les descriptions de transaction en enfants React uniquement. Les libellés affichés dans le menu et le dialog viennent de CSV imports (untrusted). Une utilisation naïve de dangerouslySetInnerHTML ou de title= avec HTML réintroduirait XSS dans le webview Tauri. Resolution : Spécifier dans la spec que les descriptions sont rendues comme enfants React (jamais dangerouslySetInnerHTML) et tronquées via CSS uniquement. Ref : CWE-79

Données

Aucune migration SQL. Toutes les requêtes s'appuient sur les tables existantes :

  • transactions — agrégats mensuels, tops, filtres date/catégorie.
  • categories — hiérarchie pour rollup, couleurs, types.
  • keywords — insertion nouvelle règle via dialog contextuel.
  • budget_entries — réel vs budget.
  • import_sources — filtres optionnels.

Nouveaux endpoints dans reportService.ts (SQL strictement paramétré, jamais d'interpolation) :

Fonction Rôle
getHighlights(from, to) Retourne { netBalanceCurrent, netBalanceYtd, monthlyBalanceSeries, topMovers: {category, deltaAbs, deltaPct}[], topTransactions: Transaction[] }
getCompareMonthOverMonth(year, month) Retourne CategoryDelta[] pour mois cible vs mois précédent
getCompareYearOverYear(year) Retourne CategoryDelta[] pour année cible vs année précédente
getCategoryZoom(categoryId, from, to, includeSubcategories) Retourne { rollupTotal, byChild, monthlyEvolution, transactions }

getCategoryZoom — cycle guard obligatoire : le rollup des sous-catégories passe par une CTE SQLite récursive bornée pour se protéger contre d'éventuels cycles dans parent_id :

WITH RECURSIVE cat_tree(id, depth) AS (
  SELECT id, 0 FROM categories WHERE id = ?1
  UNION ALL
  SELECT c.id, ct.depth + 1
  FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id
  WHERE ct.depth < 5
)
SELECT ... WHERE category_id IN (SELECT id FROM cat_tree);

Un test unitaire avec fixture cyclique (A→B→A) doit valider la terminaison.

Le service getBudgetVsActualData de budgetService.ts est réutilisé tel quel pour le mode réel-vs-budget.

🔴 SECURITEgetCategoryZoom rollup récursif sans garde-fou cyclique. La table categories autorise n'importe quel parent_id (pas de check FK contre cycles). Une donnée malformée A→B→A fait tourner un walk récursif à l'infini et fige l'UI. getCategoryDepth existant a déjà ce risque latent. Resolution : Implémenter le rollup via une CTE SQLite récursive bornée (WITH RECURSIVE ... WHERE depth < 5) ou tracer un Set<visited> en JS. Ajouter un test unitaire avec une fixture cyclique. Ref : CWE-835

Architecture

Nouvelle structure des fichiers

Convention : src/pages/ et src/components/reports/ restent plats (aucun sous-dossier par domaine), cohérent avec le reste du projet. Distinction par préfixe de nom.

src/pages/                              # plat, comme le reste du projet
├── ReportsPage.tsx                     # refonte : devient le hub
├── ReportsHighlightsPage.tsx           # NOUVEAU
├── ReportsTrendsPage.tsx               # NOUVEAU
├── ReportsComparePage.tsx              # NOUVEAU
└── ReportsCategoryPage.tsx             # NOUVEAU

src/components/reports/                 # plat, préfixes par domaine
# Hub
├── HubHighlightsPanel.tsx              # NOUVEAU — panneau condensé pour le hub
├── HubReportNavCard.tsx                # NOUVEAU — les 4 tuiles de navigation
├── HubNetBalanceTile.tsx               # NOUVEAU — tuile solde + sparkline
├── HubTopMoversTile.tsx                # NOUVEAU
├── HubTopTransactionsTile.tsx          # NOUVEAU
# Highlights
├── HighlightsTopMoversTable.tsx        # NOUVEAU
├── HighlightsTopMoversChart.tsx        # NOUVEAU — diverging bar chart
├── HighlightsTopTransactionsList.tsx   # NOUVEAU
# Compare
├── CompareModeTabs.tsx                 # NOUVEAU
├── ComparePeriodTable.tsx              # NOUVEAU
├── ComparePeriodChart.tsx              # NOUVEAU — diverging bar chart
├── CompareBudgetView.tsx               # NOUVEAU — wrap BudgetVsActualTable
# Category zoom
├── CategoryZoomHeader.tsx              # NOUVEAU — combobox + toggle rollup
├── CategoryDonutChart.tsx              # NOUVEAU — template : dashboard/CategoryPieChart.tsx
├── CategoryEvolutionChart.tsx          # NOUVEAU
├── CategoryTransactionsTable.tsx       # NOUVEAU
# Shared (intra-reports)
├── ViewModeToggle.tsx                  # NOUVEAU — toggle graphique/tableau
├── Sparkline.tsx                       # NOUVEAU — mini chart Recharts
# EXISTANTS réutilisés tels quels
├── MonthlyTrendsChart.tsx              # par TrendsPage (flux global)
├── MonthlyTrendsTable.tsx
├── CategoryOverTimeChart.tsx           # par TrendsPage (par catégorie)
├── CategoryOverTimeTable.tsx
├── BudgetVsActualTable.tsx             # wrapé par CompareBudgetView
├── CategoryBarChart.tsx
├── CategoryTable.tsx
└── ReportFilterPanel.tsx

# Autres emplacements hors src/components/reports/
src/components/shared/ContextMenu.tsx   # NOUVEAU — shell générique (click-outside + Escape)
src/components/shared/ChartContextMenu.tsx  # REFACTORÉ — compose ContextMenu
src/components/categories/AddKeywordDialog.tsx  # NOUVEAU — domaine édition mot-clé, pas reports

# SUPPRIMÉS (pivot retiré franchement, git conserve l'historique)
src/components/reports/DynamicReport.tsx
src/components/reports/DynamicReportPanel.tsx
src/components/reports/DynamicReportTable.tsx
src/components/reports/DynamicReportChart.tsx

🔴 ARCHITECTUREsrc/pages/reports/ casse la convention flat du projet. Aucune page du projet n'utilise de sous-dossier aujourd'hui (src/pages/ est strictement plat : DashboardPage, ImportPage, BudgetPage, etc.). Introduire un sous-dossier pour un seul domaine crée une règle ad-hoc. Resolution : Garder src/pages/ plat : nommer ReportsHighlightsPage.tsx, ReportsTrendsPage.tsx, ReportsComparePage.tsx, ReportsCategoryPage.tsx à côté de ReportsPage.tsx. Cohérent avec le reste du projet.

🟡 ARCHITECTURE — Split components/reports/ en sous-dossiers incohérent. La spec crée hub/, highlights/, compare/, category/, shared/ mais laisse MonthlyTrendsChart, BudgetVsActualTable, DynamicReport* à la racine. Mix flat+nested ; les autres dossiers composants (import/, dashboard/, profile/) sont strictement plats. Resolution : Tout garder plat avec préfixes de nom (HubNetBalanceTile, HighlightsTopMovers, CompareModeTabs, CategoryDonutChart, etc.). Moins de churn git, cohérent avec le reste.

🟡 TECHNIQUETransactionContextMenu duplique ChartContextMenu existant. src/components/shared/ChartContextMenu.tsx implémente déjà click-outside + Escape handling avec un shell menu réutilisable. La spec crée un nouveau composant from scratch sans le référencer. Resolution : Généraliser ChartContextMenu en ContextMenu réutilisable (items passés en children/props) et le réutiliser pour les transactions. Ajouter la refactorisation comme tâche explicite d'Issue 5.

🟢 TECHNIQUE — Recharts 3.7 supporte déjà <Pie innerRadius> (donut). Vérifié : recharts@^3.7.0 est installé et src/components/dashboard/CategoryPieChart.tsx rend déjà un <Pie> avec patterns. Le donut est juste innerRadius, pas de nouvelle dépendance. Resolution : Référencer CategoryPieChart.tsx comme template de démarrage pour CategoryDonutChart.tsx dans Issue 5.

Routing (src/App.tsx)

Nouvelles routes imbriquées :

<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<HighlightsPage />} />
<Route path="/reports/trends" element={<TrendsPage />} />
<Route path="/reports/compare" element={<ComparePage />} />
<Route path="/reports/category" element={<CategoryZoomPage />} />

Hooks par domaine (refonte de useReports)

Le hook monolithique useReports est splitté en hooks par domaine, conformément au pattern « useReducer par domaine » documenté dans CLAUDE.md. Chaque page monte uniquement son propre hook — pas de god-object ni de refetch de champs hors-section.

Hook Rôle
useReportsPeriod Lit/écrit la période via query string (?from=YYYY-MM-DD&to=YYYY-MM-DD) avec useSearchParams de react-router. Bookmarkable. Par défaut : année civile en cours.
useHighlights Fetch + state du rapport faits saillants, useReducer dédié
useTrends idem pour tendances (sous-vue flux global / par catégorie)
useCompare idem pour comparables (mode MoM / YoY / budget)
useCategoryZoom idem pour le zoom catégorie (zoomedCategoryId, rollupChildren)

Les préférences viewMode (chart / table) sont persistées par section dans localStorage via une storageKey passée en prop à ViewModeToggle (reports-viewmode-highlights, -trends, -compare, -category).

Pendant la transition (Issue #2), useReports conserve temporairement ses champs legacy (monthlyTrends, categorySpending, etc.) recâblés sur useReportsPeriod, pour que les 4 rapports existants continuent de fonctionner jusqu'à ce qu'Issues #36 les migrent. Une fois tous les rapports migrés (Issue #8), useReports est supprimé.

🔴 ARCHITECTURE — Un seul hook partagé entre 4 routes = god-object. Avec 4 routes react-router, chaque page monte/démonte son propre hook ; un seul useReports portant section + compareMode + trendsSubView + zoomedCategoryId + rollupChildren + period perd le bénéfice du routing : chaque page refetche tout et les champs hors-section polluent l'état. Resolution : Splitter en hooks par domaine : useReportsPeriod (partagé via query string), useHighlights, useTrends, useCompare, useCategoryZoom. Cohérent avec le pattern « useReducer par domaine » documenté dans CLAUDE.md.

Suppression du pivot (tableau croisé dynamique)

Le pivot est supprimé franchement — pas de feature flag, pas de route cachée. Git conserve l'historique si on veut le ressusciter un jour. Concrètement :

  • Delete src/components/reports/DynamicReport.tsx, DynamicReportPanel.tsx, DynamicReportTable.tsx, DynamicReportChart.tsx
  • Retirer pivotConfig, pivotResult, les actions setPivotConfig et la logique tab === 'dynamic' de src/hooks/useReports.ts
  • Retirer toutes les clés reports.pivot.* dans src/i18n/locales/{fr,en}.json
  • Nettoyer le type ReportTab (plus de 'dynamic')

Sidebar (NAV_ITEMS dans src/shared/constants/index.ts) : l'entrée /reports reste seule — les 4 sous-rapports ne sont accessibles que via les cartes du hub.

🔴 TECHNIQUE + ARCHITECTUREsrc/shared/constants.ts n'existe pas + feature flag probablement YAGNI. Le projet utilise src/shared/constants/index.ts (dossier avec barrel). En plus, un flag pour du code déjà retiré de l'UI = route jamais atteinte + dette i18n (reports.pivot.* conservées) sans bénéfice clair — git garde l'historique. Resolution : Soit supprimer franchement les DynamicReport* et leurs clés i18n (git = historique), soit ajouter le flag dans src/shared/constants/index.ts avec un TODO de suppression à 2 versions. Trancher maintenant et écrire le choix dans la spec.

🟢 SECURITE — Pivot flag runtime laisse le code dans le bundle. Un constant JS ne tree-shake pas : getDynamicReportData et son FIELD_SQL dynamique (dont les filtres viennent de l'utilisateur) restent dans le JS shippé = surface d'attaque morte mais live. Resolution : Si on garde l'option, utiliser un flag build-time via import.meta.env.VITE_ENABLE_LEGACY_PIVOT ou un define Vite pour permettre au bundler d'éliminer l'import conditionnel. Ref : OWASP A05:2021

i18n

Nouveaux espaces dans src/i18n/locales/{fr,en}.json :

"reports": {
  "hub": {
    "title": "Rapports",
    "explore": "Explorer",
    "highlights": "Faits saillants",
    "trends": "Tendances",
    "compare": "Comparables",
    "categoryZoom": "Analyse par catégorie"
  },
  "highlights": {
    "netBalanceCurrent": "Solde du mois",
    "netBalanceYtd": "Solde cumulatif (YTD)",
    "topMovers": "Top mouvements",
    "topTransactions": "Plus grosses transactions récentes",
    "variationAbs": "Écart $",
    "variationPct": "Écart %",
    "vsLastMonth": "vs mois précédent"
  },
  "trends": {
    "subviewGlobal": "Flux global",
    "subviewByCategory": "Par catégorie"
  },
  "compare": {
    "modeMoM": "Mois vs mois précédent",
    "modeYoY": "Année vs année précédente",
    "modeBudget": "Réel vs budget",
    "delta": "Écart",
    "current": "Courant",
    "previous": "Précédent"
  },
  "category": {
    "selectCategory": "Choisir une catégorie",
    "includeSubcategories": "Inclure sous-catégories",
    "directOnly": "Directe seulement",
    "breakdown": "Répartition",
    "evolution": "Évolution",
    "transactions": "Transactions"
  },
  "viewMode": {
    "chart": "Graphique",
    "table": "Tableau"
  },
  "keyword": {
    "addFromTransaction": "Ajouter comme mot-clé",
    "dialogTitle": "Nouveau mot-clé",
    "willMatch": "Matchera aussi",
    "nMatches_one": "{{count}} transaction matchée",
    "nMatches_other": "{{count}} transactions matchées",
    "applyAndRecategorize": "Appliquer et recatégoriser",
    "tooShort": "Minimum 2 caractères",
    "tooLong": "Maximum 64 caractères",
    "alreadyExists": "Ce mot-clé existe déjà pour la catégorie « {{category}} ». Remplacer ?"
  },
  "empty": {
    "noData": "Aucune donnée pour cette période",
    "importCta": "Importer un relevé"
  }
}

Suffixes de pluriel : i18next v25 + react-i18next v16 exige le format v4 JSON (_one / _other), pas _plural. Les clés reports.pivot.* existantes sont supprimées avec le code du pivot (pas conservées).

🔴 TECHNIQUE — Suffixe _plural incorrect pour i18next v25. Le projet tourne avec i18next v25 + react-i18next v16 qui exige le format v4 JSON (_one / _other). Les clés existantes utilisent déjà fileCount_one / fileCount_other. nMatches_plural ne résoudra jamais, silencieusement. Resolution : Remplacer nMatches / nMatches_plural par nMatches_one / nMatches_other dans le snippet i18n et dans la task d'Issue 5.

Plan de travail

Découpage en 8 issues Forgejo (milestone spec-refonte-rapports). Les tâches détaillées vivent dans chaque issue Forgejo — cette section en donne l'index.

Issue #1 (1a) — Fondation non-breaking [type:task]

Dépendances : aucune

Supprime le pivot franchement, ajoute les 4 squelettes de pages, crée les shared components (ViewModeToggle, Sparkline, ContextMenu générique), ajoute les sous-routes, met à jour les clés i18n. N'introduit pas encore le hub ni la refonte useReports — rien n'est cassé côté rapports existants.

Issue #2 (1b) — Refonte useReports en hooks par domaine + query string période [type:task]

Dépendances : #1

Crée useReportsPeriod (query string), useHighlights, useTrends, useCompare, useCategoryZoom. Garde useReports en mode legacy temporaire pour que les 4 rapports existants continuent de tourner pendant la transition.

Issue #3 — Rapport Faits saillants + Hub [type:feature]

Dépendances : #2

Implémente getHighlights et les tuiles (HubNetBalanceTile, HubTopMoversTile, HubTopTransactionsTile), compose HubHighlightsPanel, transforme /reports en hub (grille de 4 HubReportNavCard), implémente la version détaillée ReportsHighlightsPage.

Issue #4 — Rapport Tendances [type:feature]

Dépendances : #2

ReportsTrendsPage avec sous-toggle Flux global / Par catégorie. Réutilise MonthlyTrendsChart/Table et CategoryOverTimeChart/Table existants, les recâble sur useTrends + useReportsPeriod.

Issue #5 — Rapport Comparables [type:feature]

Dépendances : #2

getCompareMonthOverMonth, getCompareYearOverYear (SQL paramétré). ReportsComparePage avec CompareModeTabs (MoM / YoY / Budget), ComparePeriodTable, ComparePeriodChart (diverging bar), CompareBudgetView (wrap de BudgetVsActualTable).

Issue #6 — Zoom catégorie + édition contextuelle mot-clé (scope limité) [type:feature]

Dépendances : #2

getCategoryZoom avec CTE récursive bornée (cycle guard). ReportsCategoryPage avec combobox + rollup, CategoryDonutChart (template dashboard/CategoryPieChart.tsx), CategoryEvolutionChart, CategoryTransactionsTable.

Édition contextuelle des mots-clés : exporte normalizeDescription, buildKeywordRegex, compileKeywords depuis categorizationService.ts ; crée src/components/categories/AddKeywordDialog.tsx avec toutes les contraintes de sécurité (SQL paramétré, validation longueur 264, transaction SQL englobante, apply uniquement aux cochées visibles, rendu XSS-safe). Branche ContextMenu uniquement sur CategoryTransactionsTable dans cette issue. Test unitaire cycle guard avec fixture cyclique.

Issue #7 — Propagation du clic droit (follow-up) [type:feature]

Dépendances : #3, #4, #5, #6

Étend ContextMenu + AddKeywordDialog aux autres tables : HighlightsTopMoversTable, HighlightsTopTransactionsList, ComparePeriodTable, MonthlyTrendsTable, CategoryOverTimeTable, et la table principale de TransactionsPage. Pas de nouveau code métier — réutilisation pure.

Issue #8 — Polish, tests, documentation, changelog [type:task]

Dépendances : #3, #4, #5, #6, #7

Smoke tests vitest (getHighlights, getCompareMonthOverMonth, getCategoryZoom avec fixture cyclique, validation longueur AddKeywordDialog). Validation manuelle des flows. Mise à jour docs/architecture.md + docs/guide-utilisateur.md + ADR. Entrées CHANGELOG.md / CHANGELOG.fr.md sous ## [Unreleased]. Suppression définitive des champs legacy de useReports. Build + tests verts.

Ordre d'exécution

#1 → #2 → #3 ─┐
         → #4 ─┤
         → #5 ─┼→ #7 → #8
         → #6 ─┘

Issues #3, #4, #5, #6 parallélisables après #2.

Fichiers concernés

Fichier Action Raison
src/pages/ReportsPage.tsx Refondre Devient le hub (#3)
src/pages/ReportsHighlightsPage.tsx Créer Sous-page plat (#1 skeleton, #3 contenu)
src/pages/ReportsTrendsPage.tsx Créer Sous-page (#1 skeleton, #4 contenu)
src/pages/ReportsComparePage.tsx Créer Sous-page (#1 skeleton, #5 contenu)
src/pages/ReportsCategoryPage.tsx Créer Sous-page (#1 skeleton, #6 contenu)
src/App.tsx Modifier Ajout des 4 sous-routes (#1)
src/hooks/useReports.ts Refondre → supprimer Nettoyage pivot (#1), déprécié (#2), supprimé (#8)
src/hooks/useReportsPeriod.ts Créer Période via query string (#2)
src/hooks/useHighlights.ts Créer Hook domaine (#2)
src/hooks/useTrends.ts Créer Hook domaine (#2)
src/hooks/useCompare.ts Créer Hook domaine (#2)
src/hooks/useCategoryZoom.ts Créer Hook domaine (#2)
src/services/reportService.ts Étendre getHighlights (#3), getCompareMoM/YoY (#5), getCategoryZoom CTE bornée (#6)
src/services/categorizationService.ts Étendre Exporter normalizeDescription, buildKeywordRegex, compileKeywords (#6)
src/components/reports/* (plat, préfixes) Créer Hub*, Highlights*, Compare*, Category*, ViewModeToggle, Sparkline
src/components/reports/DynamicReport*.tsx Supprimer Pivot supprimé franchement (#1)
src/components/shared/ContextMenu.tsx Créer Shell générique clic droit (#1)
src/components/shared/ChartContextMenu.tsx Refactorer Compose ContextMenu (#1)
src/components/categories/AddKeywordDialog.tsx Créer Dialog édition mot-clé avec garanties sécurité (#6)
src/shared/constants/index.ts Aucun changement NAV_ITEMS /reports reste seul point d'entrée
src/i18n/locales/fr.json + en.json Étendre + nettoyer Ajouter clés hub/highlights/trends/compare/category/keyword/empty/viewMode ; supprimer reports.pivot.* (#1)
CHANGELOG.md + CHANGELOG.fr.md Modifier Entrée ## [Unreleased] (#8)
docs/architecture.md Modifier Section Rapports mise à jour (#8)
docs/guide-utilisateur.md Modifier Nouveau flow utilisateur (#8)
docs/adr/NNNN-refonte-rapports.md Créer Décision architecturale (#8)

Critères d'acceptation

  • La page /reports affiche un hub avec un panneau Faits saillants en haut et 4 cartes de navigation.
  • Chacune des 4 sous-pages (/reports/highlights, /trends, /compare, /category) est accessible et fonctionnelle.
  • Le toggle graphique/tableau fonctionne sur toutes les sous-pages et la préférence est mémorisée par rapport.
  • Le rapport faits saillants affiche solde mois courant, solde YTD (avec sparklines), top movers par catégorie et top transactions récentes.
  • Le rapport tendances expose flux global et par catégorie via un toggle interne.
  • Le rapport comparables permet de basculer entre MoM, YoY et Réel vs budget en conservant la période.
  • Le rapport zoom catégorie inclut automatiquement les sous-catégories et offre un toggle pour se limiter aux transactions directes.
  • Le donut chart de répartition s'affiche avec les couleurs des catégories et les patterns SVG.
  • Clic droit sur une transaction ouvre un menu permettant d'ajouter un mot-clé.
  • Le dialog d'ajout de mot-clé montre la preview des transactions qui seront recatégorisées, avec cases à cocher individuelles.
  • Appliquer le mot-clé met à jour keywords + les transactions cochées dans une transaction SQL englobante (BEGIN/COMMIT/ROLLBACK).
  • La validation de longueur du mot-clé (264 caractères, rejet whitespace-only) est active côté dialog.
  • getCategoryZoom termine correctement avec une fixture de catégories cyclique (test unitaire).
  • Le tableau croisé dynamique est complètement supprimé du code et des traductions.
  • Les 4 rapports respectent la signature visuelle : palette couleurs catégorie + patterns SVG + Recharts.
  • Toutes les chaînes sont traduites en FR et EN.
  • CHANGELOG.md et CHANGELOG.fr.md sont mis à jour sous ## [Unreleased].
  • npm run build et cargo check passent verts.
  • Un smoke test vitest couvre getHighlights et un rapport comparables.

Edge cases et risques

Cas Mitigation
Profil vide (aucune transaction) Chaque rapport affiche un empty-state i18n avec CTA vers /import
Catégorie sans sous-catégories mais rollup activé Le donut affiche uniquement la catégorie elle-même, pas de plantage
Transaction sans catégorie dans top transactions Afficher "Non catégorisé" avec couleur #9ca3af existante
Mot-clé trop court / trop long / whitespace-only Validation dialog : longueur 264 après .trim(), rejet avec i18n reports.keyword.tooShort / tooLong avant INSERT (prévient ReDoS)
Mot-clé qui matcherait des centaines de transactions Dialog limite l'affichage à 50 matches. Si plus, checkbox explicite « Appliquer aussi aux N-50 non affichées » (off par défaut). Apply ne touche QUE les lignes cochées réellement visibles, sauf confirmation explicite
Mot-clé avec regex spéciale (ex : *, ?) buildKeywordRegex échappe déjà les metacharactères — couvert par test
Clic droit sur transaction déjà dans la catégorie cible Le dialog propose juste d'ajouter le mot-clé, sans UPDATE inutile ; afficher "déjà classée"
Conflit de mot-clé (déjà existant pour autre catégorie) Dialog affiche reports.keyword.alreadyExists. « Remplacer » = UPDATE keywords SET category_id=? WHERE keyword=? + re-run catégorisation uniquement sur matches visibles cochés (pas rétroactif sur l'historique)
Crash au milieu de l'apply (INSERT ok, UPDATE partiel) Tout l'apply tourne dans une transaction SQL (BEGIN/COMMIT/ROLLBACK). En cas d'échec, rollback complet + toast erreur
Catégorie avec cycle dans parent_id (A→B→A) getCategoryZoom utilise une CTE récursive bornée WHERE depth < 5. Test unitaire avec fixture cyclique
Year-over-year sans données année précédente Afficher empty-state i18n reports.empty.noData
Budget absent dans Réel vs budget Réutiliser le comportement existant de BudgetVsActualTable
Profil existant avec tab: 'dynamic' en localStorage Fallback sur hub à l'ouverture — le pivot n'existe plus
Période personnalisée très longue (5+ ans) Laisser passer, Recharts gère bien jusqu'à ~60 points
XSS via description de transaction importée Descriptions rendues comme enfants React uniquement (jamais dangerouslySetInnerHTML), troncature CSS

Décisions prises

Question Décision Raison
Organisation UI Hub + 4 sous-pages Offre un récit (faits saillants d'abord) + place pour chaque rapport
Contenu faits saillants Top mouvements + top transactions + solde mois/YTD Répond à la question "qu'est-ce qui a bougé ?" sans complexifier
Contenu comparables MoM, YoY, Réel vs budget avec navigation facile Couvre les 3 comparaisons naturelles (temps court, temps long, vs plan)
Analyse ponctuelle Zoom sur catégorie (pas pivot) 90% des usages du pivot revenaient à zoomer une catégorie
Sort du pivot Supprimé franchement (code + i18n + types) Git conserve l'historique. Feature flag runtime laisserait le code dans le bundle (surface d'attaque morte) + dette i18n inutile. YAGNI
Contenu tendances Flux global + par catégorie uniquement Retire projection et moyennes mobiles (hors scope)
Toggle chart/table Partout, défaut graphique, mémorisé localStorage Cohérence + flexibilité + mémoire utilisateur
Librairie de charts Conserver Recharts + patterns SVG Déjà en place, adapté aux petits datasets, signature visuelle intacte
Nouveaux types Sparklines + donut chart Utiles pour faits saillants et répartition, faisables en Recharts natif
Rollup sous-catégories Auto activé, toggle pour désactiver Intuition conforme à l'arbre, reste contrôlable
Édition mots-clés Clic droit contextuel sur transaction Contexte naturel, pas de duplication d'UI dans categories
Reclassification Preview + apply Sécurité + feedback + contrôle utilisateur
Période par défaut Année civile en cours Naturel fiscalement, cohérent avec budget
Découpage issues 8 issues : #1 fondation non-breaking, #2 refonte hooks, #36 rapports (parallélisables), #7 propagation clic droit, #8 polish Split fondation en 1a+1b évite de casser les rapports existants pendant la refonte du hook. Issue #7 de follow-up évite que Issue #6 dépende de #3/#4/#5
Partage de période entre routes Query string ?from=YYYY-MM-DD&to=YYYY-MM-DD via useSearchParams Bookmarkable, pas de contexte React global, cohérent avec le reste du projet
Hook useReports Split en hooks par domaine (useReportsPeriod, useHighlights, useTrends, useCompare, useCategoryZoom) Chaque route monte uniquement son hook, pas de god-object, pas de refetch de champs hors-section
Structure src/pages/ Plate (pas de sous-dossier) Cohérent avec le reste du projet (DashboardPage, ImportPage, etc.)
Structure src/components/reports/ Plate avec préfixes de nom (HubNetBalanceTile, etc.) Cohérent avec les autres dossiers composants (import/, dashboard/, profile/) qui sont plats
AddKeywordDialog emplacement src/components/categories/ Domaine édition mot-clé, utilisé hors reports (page transactions aussi)
ContextMenu générique Créer src/components/shared/ContextMenu.tsx + refactorer ChartContextMenu pour le composer Évite de dupliquer la logique click-outside + Escape déjà dans ChartContextMenu
Sécurité du dialog mot-clé SQL paramétré + longueur 264 + transaction SQL + apply sur cochées visibles + rendu React children 3 critiques sécurité (SQL injection, ReDoS, transaction atomique) + 1 XSS latent dans le webview Tauri
Cycle guard rollup catégorie CTE SQLite récursive bornée WHERE depth < 5 + test fixture cyclique categories.parent_id ne protège pas contre les cycles, risque de boucle infinie

Références

Source Pertinence
My Take on React Chart Libraries — Kyle Gill Confirme que Recharts reste un choix solide pour React + petits datasets ; pas de raison de migrer
Top React Chart Libraries 2026 — Querio Compare Recharts / Visx / ECharts ; ECharts pertinent uniquement >100k points, pas notre cas
Recharts — Create a Donut Chart (GeeksforGeeks) Donut chart = Pie avec innerRadius, pas de dépendance supplémentaire
MUI X — Sparkline Chart Pattern sparkline : LineChart compact sans axes, intégrable dans une tuile

Revision — Synthese

Date : 2026-04-13 | Experts : Securite, Architecture, Technique

Verdict

🔴 CRITIQUES A CORRIGER — L'orientation produit (hub + 4 rapports) est validée, mais 9 findings critiques doivent être résolus avant d'ouvrir les issues : incohérences avec la structure du projet, références à du code inexistant, et risques de sécurité autour de l'édition contextuelle des mots-clés.

Resume

Expert 🔴 🟡 🟢 Points cles
Securite 3 3 2 ReDoS + SQL injection sur dialog mot-clé, absence de cycle guard rollup catégorie, transaction SQL manquante
Architecture 3 3 1 pages/reports/ casse la convention flat, useReports god-object, partage de période non spécifié
Technique 3 3 2 normalizeString inexistant, constants.ts mauvais path, i18next v25 exige _one/_other

Actions requises

🔴 Critiques à corriger avant d'ouvrir les issues

  1. 🔴 Preview SQL doit être paramétrée — charger les candidats via LIKE ? puis filtrer en mémoire avec buildKeywordRegex ; jamais de string concat.
  2. 🔴 ReDoS : cap la longueur du mot-clé — validation 264 caractères dans AddKeywordDialog, rejet whitespace-only.
  3. 🔴 Cycle guard sur le rollup catégorie — CTE récursive bornée (WHERE depth < 5) ou Set<visited> en JS + test unitaire.
  4. 🔴 Structure src/pages/ plate — renommer ReportsHighlightsPage.tsx, ReportsTrendsPage.tsx, etc. à côté de ReportsPage.tsx, pas de sous-dossier.
  5. 🔴 Splitter useReports — un hook par domaine (useHighlights, useTrends, useCompare, useCategoryZoom) + useReportsPeriod partagé via query string.
  6. 🔴 Mécanisme de partage de période — query string ?from=...&to=..., pas de contexte React global.
  7. 🔴 nMatches_one / nMatches_other — pas _plural (i18next v25).
  8. 🔴 Corriger src/shared/constants/index.ts — ou mieux : supprimer franchement le pivot, git garde l'historique (trancher YAGNI).
  9. 🔴 Exporter normalizeDescription et buildKeywordRegex — et corriger le nom dans la spec (pas normalizeString).

🟡 Améliorations recommandées

  1. 🟡 Wrapper INSERT + UPDATE du dialog mot-clé dans une transaction SQL explicite.
  2. 🟡 Decider et écrire le comportement de « remplacer un mot-clé existant ».
  3. 🟡 Apply ne modifie que les lignes cochées affichées (ou confirmation explicite pour les N-50 non affichées).
  4. 🟡 Garder components/reports/ plat avec préfixes de nom (HubNetBalanceTile, etc.), comme les autres dossiers composants.
  5. 🟡 Déplacer AddKeywordDialogcomponents/categories/, TransactionContextMenucomponents/shared/.
  6. 🟡 Splitter Issue 1 en 1a (routing + skeletons) et 1b (refactor useReports) pour éviter de casser les 4 rapports existants.
  7. 🟡 Trancher l'exposition Sidebar des sous-routes (probablement : seule /reports reste dans le menu).
  8. 🟡 Généraliser ChartContextMenu existant plutôt que dupliquer en TransactionContextMenu.
  9. 🟡 Scope du clic droit dans Issue 5 : limiter à CategoryTransactionsTable + issue de follow-up pour propagation.