## 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).

#### `/reports/trends`

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é.

> **🔴 TECHNIQUE** — `normalizeString` 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.

> **🟡 ARCHITECTURE** — `AddKeywordDialog` 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` :

```sql
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.

> **🔴 SECURITE** — `getCategoryZoom` 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
```

> **🔴 ARCHITECTURE** — `src/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.

> **🟡 TECHNIQUE** — `TransactionContextMenu` 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 :
```tsx
<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 #3–6 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 + ARCHITECTURE** — `src/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` :

```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 2–64, 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é (2–64 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 2–64 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, #3–6 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 2–64 + 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](https://www.kylegill.com/essays/react-chart-libraries) | Confirme que Recharts reste un choix solide pour React + petits datasets ; pas de raison de migrer |
| [Top React Chart Libraries 2026 — Querio](https://querio.ai/articles/top-react-chart-libraries-data-visualization) | Compare Recharts / Visx / ECharts ; ECharts pertinent uniquement >100k points, pas notre cas |
| [Recharts — Create a Donut Chart (GeeksforGeeks)](https://www.geeksforgeeks.org/reactjs/create-a-donut-chart-using-recharts-in-reactjs/) | Donut chart = `Pie` avec `innerRadius`, pas de dépendance supplémentaire |
| [MUI X — Sparkline Chart](https://mui.com/x/react-charts/sparkline/) | 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 2–64 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**

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