# ADR 0016 — Persistance de l'état UI par profil : le repli des catégories vit dans `user_preferences` - Status: **Accepted** - Date: 2026-07-15 - Issues: #288 (socle multi-niveaux + 3 rapports + bascule vers `user_preferences`), #289 (grille budget), #290 (unification des deux arbres de catégories), #291 (cet ADR + doc) - Spec: [`spec-decisions-collapse-multi-niveaux.md`](../../spec-decisions-collapse-multi-niveaux.md), [`spec-plan-collapse-multi-niveaux.md`](../../spec-plan-collapse-multi-niveaux.md) (plan v2) - S'appuie sur [ADR 0005](0005-multi-profile-db.md) (bases SQLite séparées par profil) et [ADR 0002](0002-useReducer-vs-redux.md) (état local, pas de store global) ## Contexte Le repli/dépli des catégories a d'abord été introduit sur les deux rapports comparables (issue #254/#265) : un `Set` d'ID de catégories par rapport, persisté en **`localStorage`**, replié par défaut. La généralisation (issues #288/#289/#290) a étendu ce mécanisme à **tous les niveaux** de la hiérarchie et à **six surfaces** : les trois tableaux de rapports hiérarchiques (comparable réel-vs-réel `ComparePeriodTable`, réel-vs-budget `BudgetVsActualTable`, tendance par catégorie `CategoryOverTimeTable`), la **grille budgétaire** (`BudgetTable`), et les **deux arbres de gestion des catégories** (`CategoryTree` de la page Catégories + `CategoryTaxonomyTree` du guide standard et de l'assistant de migration). Cette généralisation a forcé une question qui n'était pas tranchée en #254 : **où vit l'état de repli ?** Deux propriétés du produit rendent le choix structurant, pas cosmétique. 1. **L'app est multi-profils à bases séparées** ([ADR 0005](0005-multi-profile-db.md)) : chaque profil a sa **propre base SQLite**, ses propres catégories, ses propres ID de catégories. Un profil peut être **protégé par un NIP** (Argon2). Supprimer un profil = **supprimer son fichier `.db`** (`deleteProfileDb` → commande Rust `delete_profile_db`) + retirer son entrée de `profiles.json`. Rien d'autre n'est nettoyé — `deleteProfile` (`ProfileContext`) ne touche **jamais** `localStorage`. 2. **`localStorage` est machine-global**, partagé par tous les profils du poste. La clé de repli est un ensemble d'**ID de catégories** (`p:`), et ces ID n'ont de sens que **dans la base d'un profil donné** : le même ID numérique désigne des catégories différentes selon le profil. Une clé de repli en `localStorage` serait donc soit **en collision entre profils** (mêmes ID, catégories différentes), soit suffixée par `profileId` — et dans ce dernier cas, elle **survivrait à la suppression du profil**, devenant un **résidu**. Ce résidu n'est pas anodin : l'ensemble des catégories qu'un profil a **dépliées** révèle quelles postes de dépense l'utilisateur a explorés. Le laisser traîner en `localStorage` après la suppression d'un profil (potentiellement NIP-protégé) trahit une part de son comportement financier. C'est **incompatible avec le principe privacy-first** (non négociable) du projet. ## Décision **L'état UI *spécifique à un profil* est persisté dans la table `user_preferences` de la base du profil ; l'état UI *machine-global* reste dans `localStorage`.** Le repli des catégories relève du premier cas. ### 1. Frontière `user_preferences` vs `localStorage` | Nature de l'état | Support | Pourquoi | Exemples | |---|---|---|---| | **Spécifique au profil** (dépend des données du profil, ou révèle son usage) | `user_preferences` (base SQLite du profil) | Scoping par-profil **gratuit** (la clé vit dans la base du profil) ; **détruit avec le profil** (drop du `.db`) ⇒ zéro résidu | **Repli des catégories** des rapports + grille budget (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) ; `import_folder`, `balance_show_returns`, `balance_starter_proposed` (préexistants) | | **Machine-globale** (scalaire agnostique du profil, ne révèle rien de sensible) | `localStorage` | On *veut* la même valeur quel que soit le profil ouvert ; aucune fuite si elle survit à une suppression | Thème clair/sombre (`theme`), position des sous-totaux (`*-subtotals-position`), mode de période Cartes (`reports-cartes-period-mode`), bascule graphique/tableau par rapport | Le critère : une préférence est **machine-globale** quand sa valeur est un scalaire agnostique du profil (`"top"`/`"bottom"`, `"dark"`, `"chart"`) qu'on souhaite partager entre tous les profils du poste. Elle est **spécifique au profil** dès qu'elle est indexée par des identifiants propres à la base du profil (les ID de catégories du repli) **ou** qu'elle expose l'usage d'un profil donné. ### 2. Mécanisme — `useCollapsibleGroups(storageKey: string | null)` Le hook `useCollapsibleGroups` porte cette décision via un unique paramètre `storageKey` : - **`storageKey` non nul** ⇒ persistance dans `user_preferences` via `userPreferenceService` (`getPreference`/`setPreference`, UPSERT sur `key`). Utilisé par les **quatre surfaces persistées** (3 rapports + budget). La valeur sérialisée est le `Set` des clés dont l'état **diffère du défaut** — ce qui permet un défaut « tout replié » sans graine (Set vide = tout replié pour `defaultExpanded: false`). - **`storageKey === null`** ⇒ **aucune persistance**, état purement en mémoire, réinitialisé au défaut à chaque montage. Utilisé par les **deux arbres de catégories** (page Catégories, guide standard, assistant de migration) : leur repli est un confort de navigation éphémère, sans intérêt à conserver ni à révéler. Le hook reste **sans contexte** — il n'appelle jamais `useProfile()` : la clé `user_preferences` est **déjà** par-profil, puisqu'elle vit dans la base du profil actif. Aucun `profileId` à faufiler. ### 3. Rationale privacy — le résidu qui survit à la suppression C'est l'argument central. `deleteProfile` détruit la base (`.db`) mais **ne purge aucun `localStorage`**. Une clé de repli par-profil en `localStorage` (ex. `reports-compare-expanded:`) survivrait donc à la suppression du profil, laissant deviner **quelles catégories** un profil désormais supprimé — et peut-être NIP-protégé — avait explorées. En logeant l'état dans `user_preferences`, il **disparaît avec le fichier `.db`** : la suppression du profil emporte automatiquement son état UI, sans code de nettoyage dédié et sans surface de fuite. La propriété « supprimer un profil = supprimer un fichier » ([ADR 0005](0005-multi-profile-db.md)) est préservée telle quelle. ## Alternatives considérées ### A. `localStorage` suffixé par `profileId` — **rejeté** Garder le repli en `localStorage` avec une clé `…:` par profil. Rejeté : c'est précisément le **résidu** décrit ci-dessus (survit à `deleteProfile`, fuite privacy), et il faudrait ajouter un balayage de nettoyage dans `deleteProfile` — du code fragile qui doit connaître toutes les clés UI par-profil de l'app, à maintenir à chaque nouvelle surface repliable. ### B. Table SQLite dédiée (`ui_collapse_state`) — rejeté Créer une table par-profil rien que pour le repli. Rejeté : sur-dimensionné. `user_preferences` est déjà une table clé-valeur par-profil faite pour ça (`import_folder`, `balance_show_returns`…) ; une clé par surface suffit, sans migration. ### C. Tout en mémoire (aucune persistance) — rejeté pour les rapports/budget Simple, zéro résidu. Rejeté pour les **quatre** surfaces persistées : sur des tableaux profonds, re-replier à chaque visite est une friction réelle (finding remonté en #254). **Retenu en revanche pour les deux arbres de catégories** (`storageKey: null`) — d'où le paramètre nullable plutôt qu'un choix uniforme. ## Consequences ### Positives - **Zéro résidu, zéro fuite** : l'état de repli est détruit avec le `.db` du profil ; aucune trace machine-globale de l'usage d'un profil supprimé. Conforme privacy-first. - **Scoping par-profil gratuit** : la clé vit dans la base du profil ⇒ isolation automatique, hook sans contexte, pas de `profileId` à propager. - **Réutilise l'existant** : `user_preferences` + `userPreferenceService` déjà en place ⇒ **aucune migration, aucune table, aucun changement de schéma**. - **Frontière tracée** : la règle « profil-spécifique → `user_preferences` ; machine-globale → `localStorage` » est explicite (§1) ⇒ le prochain état UI par-profil ne repartira pas en `localStorage` par défaut (caveat de review levé). ### Négatives / risques actés - **Hydratation asynchrone** : `getPreference`/`setPreference` sont `async` (passent par `getDb()`). L'état initial rendu est le **défaut** (tout replié pour les rapports/budget), puis un `useEffect` hydrate depuis `user_preferences`. **Pas de flash visible** : le défaut EST ce qui s'affiche en premier, et l'hydratation ne peut que **révéler** ce que l'utilisateur avait déplié (jamais masquer ce qui était visible). Acceptable — c'est la contrepartie assumée du choix. - **Best-effort** : une lecture/écriture en échec retombe silencieusement sur l'état par défaut (jamais de crash). Une écriture perdue = la session suivante rouvre au défaut. - **Frontière à faire respecter dans la durée** : rien dans le type ne force un futur état par-profil à choisir `user_preferences`. La règle est documentaire (cet ADR + `docs/architecture.md`) — d'où l'importance de l'avoir tracée. ### Neutre - Les préférences machine-globales existantes (`theme`, `*-subtotals-position`, `reports-cartes-period-mode`, bascule graphique/tableau) **restent en `localStorage`** : elles sont volontairement partagées entre profils et ne révèlent rien de sensible en cas de suppression. Aucune migration de ces clés n'est entreprise. ## Liens - [ADR 0005](0005-multi-profile-db.md) — bases SQLite séparées par profil ; « supprimer un profil = supprimer un fichier `.db` » (fondement de l'argument résidu) - [ADR 0002](0002-useReducer-vs-redux.md) — état local par domaine, pas de store global (le repli reste un état UI local persisté à la marge) - `src/hooks/useCollapsibleGroups.ts` — implémentation (`storageKey: string | null`, `defaultExpanded`, hydratation async) - `src/utils/collapsibleRows.ts` — helpers purs (marche des ancêtres, polarité du `Set`) - `src/services/userPreferenceService.ts` — `getPreference`/`setPreference` (UPSERT sur `user_preferences`) - Issues #254/#265 (repli d'origine, localStorage) → #288/#289/#290 (généralisation + bascule `user_preferences`) → #291 (cette doc)