Initial import: LifeTrack v1 (santé, vape, finances)

Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 10:48:57 +02:00
co-authored by Claude Opus 5
commit 93f0689c1e
273 changed files with 80746 additions and 0 deletions
+910
View File
@@ -0,0 +1,910 @@
# Guide d'utilisation de LifeTrack
Ce guide décrit **comment se servir de LifeTrack au quotidien** : brancher ses vraies
données, suivre son poids, remplir son journal alimentaire, piloter le sevrage tabagique et
importer ses relevés bancaires.
Il part du principe que l'application tourne déjà et que votre compte est créé. Sinon,
commencez par le [README](../README.md#démarrage-rapide-docker-compose).
---
## Sommaire
- [Avant de commencer](#avant-de-commencer)
- [Connecter Health Connect](#connecter-health-connect)
- [Suivi poids et calories au quotidien](#suivi-poids-et-calories-au-quotidien)
- [Nutrition](#nutrition)
- [Vape et sevrage tabagique](#vape-et-sevrage-tabagique)
- [Finances](#finances)
- [Dépannage](#dépannage)
---
## Avant de commencer
Trois réglages conditionnent presque tous les calculs. Faites-les une bonne fois pour
toutes dans **Réglages** :
| Onglet | À renseigner | Ce que ça débloque |
|---|---|---|
| **Profil** | Taille, sexe, date de naissance, niveau d'activité | Métabolisme de base, dépense estimée, IMC |
| **Objectif** | Poids de départ, poids cible, mode de calcul | Budget calorique quotidien, projection, planning |
| **Vape** | Date d'arrêt, cigarettes/jour avant, prix du paquet | Économies, jalons santé |
Le profil comporte aussi un **fuseau horaire** (`Europe/Paris` par défaut). C'est lui qui
détermine à quel jour appartient une donnée horodatée : changez-le si vous vivez ailleurs,
sinon laissez-le tranquille.
---
## Connecter Health Connect
Health Connect est le magasin de données santé d'Android. Point crucial à comprendre avant
tout : **c'est une base de données locale au téléphone. Aucun serveur ne peut aller y
chercher quoi que ce soit.** Il n'existe pas d'API cloud, et l'ancienne API Google Fit est
morte (inscriptions fermées depuis mai 2024, arrêt en 2026).
Les données ne peuvent donc que **partir du téléphone vers LifeTrack**. Trois chemins, du
plus automatique au plus manuel.
### Chemin 1 — Envoi automatique vers l'API d'ingestion
#### Étape 1 : créer une clé d'appareil
1. Ouvrez **Réglages → Appareils & API**.
2. Cliquez sur **Créer une clé**.
3. Donnez-lui un nom parlant (par exemple « Pixel de Julien »).
4. Cochez la portée **« Santé (pesées, activité, séances) »** — techniquement
`ingest:health`.
5. Validez.
> ⚠️ **La clé en clair n'est affichée qu'une seule fois.** Copiez-la immédiatement.
> LifeTrack n'en conserve qu'une empreinte : personne, pas même vous, ne pourra la
> réafficher. Si vous la perdez, révoquez-la et créez-en une autre.
>
> Une clé ressemble à `ltk_` suivi d'une longue chaîne aléatoire.
La liste vous montre ensuite, pour chaque clé, son préfixe, ses portées, sa date de création
et sa dernière utilisation — pratique pour vérifier qu'une passerelle envoie bien quelque
chose.
#### Étape 2 : l'adresse à viser
L'endpoint d'ingestion est :
```
POST http://<adresse-de-votre-serveur>/api/ingest/health
```
L'onglet **Imports → Connecteurs & API** affiche cette URL déjà construite avec l'adresse
depuis laquelle vous consultez l'application, avec un bouton **Copier**.
Deux choses obligatoires dans la requête :
- l'en-tête **`X-API-Key: ltk_…`** avec votre clé d'appareil ;
- un corps JSON de la forme suivante :
```json
{
"source": "health_connect",
"records": [
{
"type": "steps",
"external_id": "uuid-fourni-par-health-connect",
"data": { "day": "2026-08-13", "steps": 9421 }
},
{
"type": "weight",
"data": { "measured_at": "2026-08-13T06:42:00Z", "weight_kg": 83.4 }
}
]
}
```
Vous pouvez tester tout de suite depuis n'importe quelle machine :
```bash
curl -X POST http://localhost/api/ingest/health \
-H "X-API-Key: ltk_votre_cle_ici" \
-H "Content-Type: application/json" \
-d '{"source":"health_connect","records":[{"type":"weight","data":{"measured_at":"2026-08-13T06:42:00Z","weight_kg":83.4}}]}'
```
La réponse indique combien d'enregistrements ont été insérés, mis à jour, ignorés comme
doublons, et le détail des lignes rejetées. **Un enregistrement invalide ne fait jamais
échouer le lot entier.**
#### Étape 3 : quelles données passent
Huit types d'enregistrements sont acceptés :
| `type` | Ce que ça alimente | Champs principaux reconnus |
|---|---|---|
| `weight` | Pesées | `measured_at` / `time` / `start_time`, `weight_kg`, `body_fat_pct` |
| `steps` | Activité quotidienne | `day` ou `start_time`, `steps` / `count` |
| `distance` | Activité quotidienne | `distance_m` ou `distance_km` |
| `active_calories` | Activité quotidienne | `active_kcal` / `active_calories` |
| `total_calories` | Activité quotidienne (TDEE mesuré) | `total_kcal` / `total_calories` |
| `exercise_session` | Séances de sport | `start_time`, `end_time` ou `duration_s`, `exercise_type`, `energy_kcal`, `distance_m`, `avg_hr`, `max_hr` |
| `nutrition` | Journal alimentaire | `eaten_at`, `energy_kcal`, `name`, `meal_type`, macros |
| `hydration` | Hydratation | `drunk_at`, `volume_ml` ou `volume_liters` |
Le format des champs est volontairement tolérant : LifeTrack accepte les noms
« Health Connect » en `snake_case`, les objets imbriqués `value` et `metadata`, les
horodatages ISO 8601 comme les époques Unix en secondes ou millisecondes. Un horodatage sans
fuseau est interprété en **Europe/Paris** puis converti en UTC. La charge utile d'origine est
toujours conservée dans la ligne créée.
Les types de sport connus sont reconnus et traduits (`running`, `walking`, `cycling`,
`swimming`, `strength_training`, `hiit`, `yoga`, `hiking`, tapis de course en marche ou en
course…) ; le reste atterrit en « Autre » avec le libellé d'origine conservé.
#### Étape 4 : la passerelle Android — et ses limites réelles
L'application open source de référence est **health-connect-webhook**
(<https://github.com/mcnaveen/health-connect-webhook>, AGPL-3.0, disponible sur le Play
Store). Elle lit Health Connect en tâche de fond et POSTe du JSON vers l'URL de votre choix.
**Soyons parfaitement clairs : en l'état, elle ne peut pas être branchée directement sur
LifeTrack.** Deux obstacles, tous deux réels :
1. **Elle ne sait pas poser d'en-tête HTTP personnalisé.** Sa sécurité repose entièrement
sur le secret de l'URL. Or `POST /api/ingest/health` exige `X-API-Key`. Les documents de
conception prévoyaient une route acceptant un jeton dans le chemin de l'URL pour ce cas
précis — **elle n'a pas été implémentée**.
2. **Son enveloppe JSON n'est pas celle attendue.** Elle envoie un objet contenant
`timestamp`, `app_version` et un tableau par type de données. LifeTrack attend
`{"source": …, "records": [{"type": …, "data": …}]}`. Le *contenu* de chaque
enregistrement est bien compris par LifeTrack ; c'est l'emballage qui diffère.
Concrètement, vous avez trois options :
- **Intercaler un petit relais** entre le téléphone et LifeTrack (un script, une
automatisation Node-RED ou n8n, une fonction sur votre serveur) qui reçoit le webhook,
découpe les tableaux en `records` et rejoue la requête avec l'en-tête `X-API-Key`. C'est
une trentaine de lignes.
- **Utiliser n'importe quelle application capable de poser un en-tête HTTP** (Tasker,
HTTP Shortcuts, Macrodroid, ou une future application compagnon) : le format canonique
ci-dessus est simple à produire.
- **Rester sur l'import CSV** (chemin 2), qui fonctionne aujourd'hui sans aucun bricolage.
#### Les autres limites, à connaître avant de s'engager
- **Fenêtre de 48 heures.** La passerelle ne relit que les 48 dernières heures. Si votre
téléphone reste éteint ou hors ligne plus longtemps, ces journées sont définitivement
perdues pour ce canal — d'où l'intérêt de l'export CSV en filet de sécurité.
- **Historique limité à 30 jours.** Health Connect n'autorise par défaut la lecture que des
30 jours précédant la première autorisation. Tout ce qui est plus ancien ne viendra
**jamais** par ce chemin : il faudra passer par un export de fichiers.
- **Installation hors Play Store.** Une application compagnon maison devra être installée
manuellement (sideload) : APK signé localement, permissions accordées dans l'écran
Health Connect du téléphone. C'est faisable, mais ce n'est pas un simple clic.
- **Économiseurs de batterie.** Xiaomi, Huawei, Samsung et consorts tuent volontiers les
tâches de fond. Si les envois s'espacent sans raison, excluez l'application de
l'optimisation de batterie dans les réglages Android.
- **Sécurité réseau.** Une clé d'appareil circule à chaque envoi. N'exposez pas LifeTrack
en clair sur Internet : restez sur votre réseau local ou passez par un VPN, sinon mettez
du HTTPS devant nginx.
### Chemin 2 — Import CSV avec Health Sync (le plus fiable aujourd'hui)
**Health Sync** (<https://healthsync.app>, environ 4 € en achat unique, essai d'une semaine)
exporte automatiquement vos données Health Connect en CSV vers Google Drive, en tâche de
fond, sans intervention.
1. Installez et configurez Health Sync sur le téléphone, avec Health Connect comme source.
2. Activez l'export automatique vers Google Drive (fichiers jour, 7 jours, mois ou 30 jours
glissants).
3. Récupérez le CSV sur votre ordinateur.
4. Dans LifeTrack, ouvrez **Imports**, déposez le fichier et laissez la **détection
automatique** faire son travail — le profil attendu est
**« Health Sync / Health Connect (export CSV) »**.
5. Vérifiez l'aperçu, puis lancez l'import.
Ce chemin est aussi **le seul moyen de rattraper un historique profond** (au-delà des
30 jours de Health Connect) et de boucher les trous laissés par la fenêtre de 48 heures.
Ré-importer un fichier déjà traité ne crée aucun doublon : allez-y sans crainte.
Un profil **« Historique de poids (CSV date/poids) »** existe également pour un simple
fichier à deux colonnes date et poids, quelle qu'en soit l'origine.
### Chemin 3 — Saisie manuelle
Toujours disponible, et parfois plus rapide qu'on ne croit :
- une pesée depuis la carte **Aujourd'hui** du tableau de bord ou depuis
**Santé → Poids & Objectif** ;
- une séance de sport depuis **Santé → Activité & Sport** ;
- un repas depuis **Santé → Nutrition**.
C'est le mode de repli recommandé pour les séances de tapis de course : l'application
FitShow ne partage ses données ni avec Health Connect, ni avec Google Fit, ni sous forme de
fichiers. Ses séances resteront enfermées dans son application tant qu'un enregistrement
Bluetooth direct n'aura pas été développé (envisagé en v3).
---
## Suivi poids et calories au quotidien
### Le planning de pesées
LifeTrack ne vous demande pas de cocher des cases : **une habitude est considérée comme faite
dès qu'une donnée existe pour ce jour-là.** Vous vous pesez, la pesée compte. Vous
enregistrez un repas, le journal alimentaire du jour compte.
Pour définir votre plan, allez dans **Réglages → Objectif → Planning de suivi** (ou depuis
la page **Santé → Poids & Objectif**). Trois habitudes indépendantes :
| Habitude | Validée par |
|---|---|
| **Pesée** | Une pesée enregistrée ce jour-là |
| **Séance** | Une séance de sport ce jour-là |
| **Journal alimentaire** | Au moins un aliment enregistré ce jour-là |
Pour chacune, cochez les jours de la semaine concernés et activez-la. Une pesée quotidienne
donne la tendance la plus fiable ; trois fois par semaine suffisent largement.
LifeTrack en déduit ensuite :
- l'**assiduité** : jours faits ÷ jours planifiés, en pourcentage ;
- la **série en cours** et la **meilleure série**, comptées uniquement sur les jours
planifiés ;
- un **calendrier** à quatre états : fait, manqué, fait hors planning, jour de repos.
Détail appréciable : **le jour même ne casse jamais une série tant qu'il n'est pas fini.**
Si vous vous pesez habituellement le matin et que vous consultez l'application à midi sans
l'avoir fait, votre série reste intacte.
### La carte « Aujourd'hui »
En haut du tableau de bord, elle résume l'état du jour : ce qui était prévu, ce qui est fait,
et un bouton d'action directe pour chaque manque (**Noter ma pesée**, **Noter ma séance**).
C'est le point d'entrée quotidien de l'application.
### La saisie rapide d'une pesée
Le bouton **Noter ma pesée** ouvre une modale minimale : le poids, éventuellement la date et
l'heure si ce n'est pas maintenant. Deux secondes, et la tendance se recalcule.
Une seule règle à connaître : **c'est la première pesée du jour qui alimente la tendance.**
Si vous vous pesez trois fois dans la journée, seule celle du matin compte pour la courbe —
les autres restent visibles dans l'historique.
### Comprendre les chiffres
C'est ici que LifeTrack cesse d'être un carnet et devient un outil. Quatre notions, dans
l'ordre où elles s'enchaînent.
#### 1. Le métabolisme de base (BMR)
C'est ce que votre corps brûle **au repos complet**, juste pour rester en vie. LifeTrack
utilise la formule de **Mifflin-St Jeor**, la référence actuelle :
```
BMR = 10 × poids(kg) + 6,25 × taille(cm) 5 × âge(ans) + correctif
```
Le correctif vaut `+5` pour un homme, `161` pour une femme, `78` pour « autre ».
*Exemple : un homme de 40 ans, 178 cm, 83 kg → 10 × 83 + 6,25 × 178 5 × 40 + 5 = **1 747
kcal/jour**.* C'est ce que vous brûleriez en restant couché toute la journée.
#### 2. La dépense énergétique totale (TDEE)
C'est le BMR **plus tout le reste** : marcher, travailler, faire du sport, digérer.
LifeTrack le calcule chaque jour selon trois méthodes, par ordre de préférence :
1. **Mesuré** — si votre montre ou votre téléphone a envoyé une valeur de calories totales
crédible (supérieure à 80 % du BMR), c'est elle qui gagne. C'est la plus juste.
2. **BMR + calories actives** — si vous n'avez que les calories *actives* de la journée,
elles s'ajoutent au BMR.
3. **Estimé** — sinon, le BMR est multiplié par le facteur de votre niveau d'activité :
| Niveau | Facteur |
|---|---|
| Sédentaire | × 1,2 |
| Légèrement actif | × 1,375 |
| Modérément actif | × 1,55 |
| Très actif | × 1,725 |
| Extrêmement actif | × 1,9 |
Le TDEE utilisé pour votre budget est **lissé sur 7 jours** : une journée exceptionnelle ne
fait pas bondir votre autorisation calorique du lendemain.
#### 3. Les 7 700 kcal par kilo
C'est la constante qui relie calories et poids : **perdre 1 kg de masse corporelle
correspond à un déficit d'environ 7 700 kcal.**
D'où le calcul du budget quotidien :
```
Budget = TDEE lissé (rythme visé en kg/semaine × 7 700 7)
```
Autrement dit, chaque tranche de **0,1 kg/semaine** que vous visez coûte **110 kcal par
jour**.
*Exemple : TDEE lissé à 2 500 kcal, objectif 0,5 kg/semaine → déficit de 0,5 × 1 100 = 550
kcal/jour → **budget de 1 950 kcal**.*
Un **plancher de sécurité** s'applique : 1 500 kcal pour un homme, 1 200 kcal pour une
femme, sauf valeur personnalisée. Si votre objectif passe sous ce plancher, le budget est
relevé et l'interface vous avertit que le rythme visé est trop agressif.
#### 4. La tendance, pas la balance
Votre poids varie de un à deux kilos d'un jour à l'autre pour des raisons qui n'ont rien à
voir avec la graisse : sel, hydratation, digestion, cycle hormonal. Se fier au chiffre brut,
c'est se condamner à l'angoisse.
LifeTrack calcule donc une **moyenne mobile exponentielle** (méthode dite « Hacker's Diet »,
facteur de lissage 0,1) : chaque nouvelle pesée ne déplace la courbe que de 10 % de l'écart
constaté. Les jours sans pesée sont correctement compensés, sans fausser le lissage.
**C'est cette courbe de tendance qu'il faut regarder, pas les points.** À partir de trois
pesées, LifeTrack calcule aussi une pente par régression linéaire et en déduit la **date
d'atteinte estimée** de votre objectif. Trois réponses possibles :
- une date, si la pente va dans le bon sens ;
- **« objectif atteint »**, à moins de 100 g de la cible ;
- **« ne converge pas »**, si la pente est nulle, part dans le mauvais sens, ou donne une
échéance à plus de dix ans. Ce n'est pas un bug : c'est une information.
#### 5. La calibration adaptative
Après **21 jours au moins** de journal alimentaire renseigné, la page **Balance énergétique**
compare la perte de poids *prévue* par vos déficits cumulés à la perte *réellement observée*
sur la tendance, et en déduit votre **TDEE réel**.
C'est la fonction la plus utile de LifeTrack sur la durée : elle corrige d'un coup les
erreurs de la formule théorique, la sous-estimation systématique des portions et l'adaptation
métabolique. Si l'écart est important, un bouton vous propose d'adopter cette valeur mesurée.
---
## Nutrition
### Recherche d'aliments avec Open Food Facts
Dans **Santé → Nutrition**, le champ de recherche interroge, dans cet ordre :
1. **le cache local** de LifeTrack — tous les aliments déjà consultés ;
2. **Open Food Facts**, la base collaborative mondiale (plus de 3 millions de produits, très
bonne couverture des rayons français).
Tapez au moins **2 caractères**. La recherche est en français et passe par le serveur, jamais
par votre navigateur.
**Chaque produit consulté est mis en cache définitivement.** Un produit que vous mangez
souvent n'est téléchargé qu'une seule fois, puis répond instantanément, même hors ligne.
Vous pouvez aussi chercher par **code-barres**.
Les valeurs sont mémorisées pour 100 g : calories, protéines, glucides, sucres, lipides,
acides gras saturés, fibres, sel. Quand les calories manquent mais que les kilojoules sont
présents, la conversion est faite automatiquement (÷ 4,184).
> **Open Food Facts est collaboratif : les données ne sont pas garanties.** Un produit peut
> avoir des valeurs fantaisistes ou une portion mal renseignée. Un coup d'œil au chiffre
> avant de valider évite bien des surprises. Si la base est injoignable, LifeTrack vous le
> dit clairement et vous propose la saisie manuelle.
> Pas de base d'aliments génériques française à ce jour. La table CIQUAL de l'ANSES
> (« Pomme, crue », « Baguette courante »…) était prévue mais **n'a pas été intégrée**. Pour
> vos plats maison, saisissez les valeurs à la main — ou créez-vous un favori une bonne fois
> pour toutes.
### Favoris et aliments récents
Deux raccourcis qui font toute la différence sur la durée :
- **Favoris** — vos aliments et portions habituels, épinglés en tête de liste. Votre café du
matin, votre yaourt, votre portion de riz.
- **Récents** — les derniers aliments enregistrés, proposés automatiquement.
Le journal est organisé par repas : petit-déjeuner, déjeuner, dîner, collation. Une entrée
sans type de repas explicite est classée selon l'heure (avant 11 h, petit-déjeuner ; avant
15 h, déjeuner ; avant 18 h, collation ; ensuite, dîner).
L'**hydratation** se suit à part, avec un objectif quotidien réglable dans le profil
(2 000 ml par défaut).
### Importer son historique Foodvisor
Foodvisor **n'expose aucune API** : ni OAuth, ni webhook, ni endpoint « mes repas ». Aucune
synchronisation continue n'est possible. Le seul chemin est un **export ponctuel**, à faire
une fois pour rapatrier votre historique.
#### Étape 1 : demander l'export dans l'application
Dans Foodvisor : **Réglages → Compte → « Demander mes données »** (l'intitulé et
l'emplacement varient selon les versions ; sur le tableau de bord web, cherchez sous
`Account → Privacy → Data`).
L'export arrive **par e-mail**, généralement sous 24 à 72 heures.
#### Étape 2 : si l'option est absente ou l'export incomplet, invoquez le RGPD
Foodvisor est une société française : vous disposez du **droit d'accès (article 15)** et du
**droit à la portabilité (article 20)**. Écrivez à **`data@foodvisor.io`**. Modèle :
> Objet : Demande d'accès et de portabilité de mes données personnelles (RGPD)
>
> Bonjour,
>
> Titulaire d'un compte Foodvisor associé à l'adresse `<votre e-mail>`, je souhaite exercer
> mes droits d'accès (article 15 du RGPD) et de portabilité (article 20).
>
> Je vous demande de me transmettre **l'intégralité de mon journal alimentaire dans un format
> structuré, couramment utilisé et lisible par machine (CSV ou JSON)**, incluant pour chaque
> entrée : l'horodatage, le type de repas, le nom de l'aliment, la portion et les valeurs
> nutritionnelles (calories et macronutriments). Merci d'y joindre également mon historique
> de poids et mes objectifs.
>
> Le délai légal de réponse est d'un mois à compter de la réception de la présente.
>
> Cordialement,
> `<votre nom>`
Le délai légal est d'**un mois**, prolongeable de deux mois pour une demande complexe.
#### Étape 3 : importer dans LifeTrack
1. Ouvrez **Imports**.
2. Déposez le CSV du journal alimentaire (si vous avez reçu une archive ZIP, extrayez-la
d'abord : c'est le fichier contenant une ligne par aliment consommé qui nous intéresse).
3. Laissez la détection automatique, ou choisissez le profil **« Foodvisor (export CSV) »**.
4. Vérifiez l'aperçu, puis lancez l'import.
L'importeur est volontairement tolérant : il accepte les en-têtes en français comme en
anglais, les séparateurs `,` et `;`, les encodages UTF-8 et Windows-1252, la virgule
décimale. Chaque ligne doit au minimum comporter **une date, un nom d'aliment et des
calories** — les autres colonnes sont facultatives.
Ce que l'export Foodvisor **ne contient pas**, et que vous ne récupérerez donc jamais : les
photos, les scores de confiance de l'IA, les codes-barres scannés, les micronutriments
au-delà des macros, et la décomposition des recettes en ingrédients.
---
## Vape et sevrage tabagique
### Étape 1 : la référence tabac
Rien ne fonctionne sans elle. **Réglages → Vape** :
| Champ | Exemple | Rôle |
|---|---|---|
| **Date d'arrêt du tabac** | 12/03/2026 | Point de départ des économies et des jalons santé |
| **Cigarettes par jour** | 20 | Votre consommation **avant** l'arrêt |
| **Cigarettes par paquet** | 20 | Généralement 20 |
| **Prix du paquet** | 12,50 € | Prix **actuel** du paquet |
| **Taux de nicotine par défaut** | 6 mg/ml | Utilisé quand une recharge n'en précise pas |
Cette référence est **gelée** : elle représente ce que vous dépenseriez si vous n'aviez pas
arrêté. Elle n'est jamais recalculée à partir de vos données de vape.
> **Une nuance d'honnêteté que LifeTrack affiche lui-même :** le prix retenu est le prix
> d'aujourd'hui, pas celui du jour de votre arrêt. Comme le tabac augmente régulièrement, les
> économies affichées sur les périodes anciennes sont **légèrement optimistes**. C'est
> assumé, et c'est indiqué dans l'interface.
Tant que ce formulaire n'est pas rempli, le module vape affiche « Module vape non
configuré » et refuse de calculer quoi que ce soit — c'est normal.
### Étape 2 : saisir les recharges
Onglet **Consommation**, bouton d'ajout. Deux façons de déclarer :
- **Recharge** (`refill`) — le remplissage habituel : la date et le volume en ml. Plusieurs
recharges dans la journée s'additionnent.
- **Total du jour** (`daily_total`) — vous n'avez pas compté vos remplissages mais vous savez
que vous avez consommé 6 ml aujourd'hui. **Cette valeur remplace la somme des recharges du
jour**, elle ne s'y ajoute pas.
Vous pouvez préciser le taux de nicotine et la recette utilisée ; sinon les valeurs par
défaut s'appliquent.
Une journée sans aucune saisie n'est **pas** comptée comme zéro : elle est marquée « non
suivie ». Les moyennes ne portent que sur les jours réellement suivis, et l'interface
affiche la proportion de jours suivis pour que vous sachiez quelle confiance accorder aux
chiffres.
### Étape 3 : changer une résistance en un clic
Onglet **Résistances**. Un seul bouton : **la date et l'heure sont celles de maintenant**,
sans aucune saisie. C'est la fonction la plus utilisée du module, elle devait être
instantanée.
LifeTrack en déduit :
- la **durée de vie** de la résistance précédente, affichée immédiatement après le clic ;
- la **durée de vie moyenne**, calculée sur les **5 derniers cycles** (14 jours tant qu'il
n'y a pas au moins deux changements) ;
- le **volume d'e-liquide passé** dans chaque résistance ;
- l'**âge de la résistance en cours**.
Vous pouvez rattacher un produit du catalogue au changement pour que son prix entre dans le
calcul d'amortissement.
### Étape 4 : le modèle de coût DIY
Onglet **Coûts & modèle**. Deux niveaux, selon votre patience.
**Le catalogue de produits.** Chaque produit porte un prix et une contenance : base
(1 000 ml), booster nicotine (10 ml), arôme (30 ml), résistances (paquet de 5). C'est tout ce
dont LifeTrack a besoin.
**Les recettes.** Une recette déclare un volume total, un taux de nicotine visé et ses
composants. Le coût se calcule simplement :
```
coût d'un composant = quantité × (prix du flacon contenance du flacon)
coût au ml = somme des composants volume total
```
*Exemple pour 100 ml : 20 ml de booster à 1 € les 10 ml (2,00 €) + 10 ml d'arôme à 6 € les
30 ml (2,00 €) + 70 ml de base à 12 € le litre (0,84 €) = **4,84 € pour 100 ml, soit 4,8
centimes par ml**.*
Un **assistant de recette** fait le calcul inverse : donnez-lui un volume total, un taux de
nicotine visé et le taux de votre booster, il vous rend les millilitres de booster, d'arôme
et de base. LifeTrack recalcule ensuite le taux de nicotine réel de la recette et vous
**avertit si l'écart avec la cible dépasse 10 %**.
Si vous n'avez pas envie de saisir des recettes, LifeTrack se rabat sur la **moyenne pondérée
de vos achats de liquide** sur les 90 derniers jours. Moins précis, mais sans effort.
**Le coût réel par jour** additionne :
```
coût/jour = ml consommés × coût au ml + (prix d'une résistance durée de vie moyenne)
```
L'amortissement de la résistance est souvent la surprise du calcul : une résistance à 2,50 €
qui tient 10 jours, c'est 25 centimes par jour, parfois plus que le liquide lui-même.
L'onglet affiche en parallèle vos **dépenses réelles** (issues de vos achats saisis) : l'écart
avec le coût théorique révèle vos stocks et vos achats d'impulsion.
### Étape 5 : lire ses économies
Onglet **Économies**. Le principe :
```
économies cumulées = (coût tabac de référence × jours écoulés) (coût de vape cumulé)
```
Les journées sans saisie de recharge ne sont pas comptées à zéro — ce serait tricher et
gonfler artificiellement les économies. Elles sont **imputées à votre moyenne des 30 derniers
jours**.
Deux courbes sont proposées : les **économies théoriques** (à partir du coût au ml calculé)
et les **économies réelles** (à partir de vos achats effectifs). La seconde est la vérité
comptable ; la première est plus stable.
À côté : cigarettes évitées, paquets évités, et **temps de vie récupéré** (compté sur la base
de 11 minutes par cigarette). L'équivalence nicotine ↔ cigarettes est donnée à titre indicatif
sur la base de 12 mg par cigarette — c'est un ordre de grandeur, pas une mesure.
Enfin, les **jalons santé** : douze étapes datées depuis votre arrêt, de la fréquence
cardiaque qui redescend après 20 minutes jusqu'au risque coronarien redevenu normal après
15 ans, avec la progression vers le prochain palier.
---
## Finances
### Étape 1 : exporter un relevé depuis sa banque
Connectez-vous à l'espace client de votre banque et cherchez « Télécharger », « Exporter » ou
« Historique des opérations ». **Préférez toujours l'OFX au CSV quand il est proposé** : ce
format embarque un identifiant unique par opération, ce qui rend la déduplication parfaite.
Presets intégrés à LifeTrack :
| Banque | Format testé | À savoir |
|---|---|---|
| **BoursoBank / Boursorama** | CSV `;`, UTF-8 | Plusieurs années disponibles, période libre |
| **Crédit Agricole** | CSV `;`, ISO-8859-15 | Préambule de longueur variable ; 30 à 90 jours selon la caisse |
| **BNP Paribas** | CSV `;`, ISO-8859-1 | Première ligne = solde, pas d'en-tête de colonnes ; environ 90 jours |
| **Société Générale** | CSV `;`, ISO-8859-1 | Environ 6 mois d'historique |
| **La Banque Postale** | CSV `;`, ISO-8859-15 | Préambule d'environ 8 lignes ; environ 90 jours |
| **Caisse d'Épargne** | CSV `;`, ISO-8859-1 | Colonnes Débit/Crédit séparées, crédit préfixé `+` |
| **Fortuneo** | CSV `;`, encodage détecté | Jusqu'à environ 10 ans d'historique |
| **Revolut** | CSV `,`, UTF-8 | Historique complet ; les frais sont déduits du montant |
| **N26** | CSV `,`, UTF-8 | Historique complet |
| **PayPal** | CSV `,` | Rapport d'activité, 7 ans par tranches de 12 mois |
| **OFX** | `.ofx` / `.qfx` | Toutes banques — **à privilégier** |
| **CSV générique** | tout CSV | Avec mappage de colonnes ajustable |
Une banque absente de la liste ? Le **CSV générique** avec l'éditeur de mappage traite
n'importe quel fichier tabulaire.
> Les banques modifient la mise en page de leurs exports sans prévenir. C'est précisément
> pour cela que l'aperçu existe : **vérifiez-le systématiquement**.
### Étape 2 : créer un compte de destination
Avant le premier import, créez le compte correspondant dans **Finances → Comptes** : un nom,
un type (courant, épargne, PayPal, espèces). Chaque transaction est rattachée à un compte —
c'est ce qui permet ensuite de détecter les virements entre vos propres comptes.
### Étape 3 : l'aperçu avant import
Déposez le fichier depuis **Imports** ou depuis le module **Finances**, choisissez le compte
et le profil bancaire. **Rien n'est écrit en base à ce stade.**
L'aperçu vous montre :
- les **20 premières lignes** telles que LifeTrack les a comprises : date, libellé, montant ;
- le nombre de lignes lues, de **doublons qui seront ignorés**, et de lignes en erreur ;
- la **période couverte** par le fichier ;
- un avertissement si ce fichier a **déjà été importé** à l'identique.
Contrôlez trois choses : les dates ne sont pas inversées (13/08 ne doit pas devenir le
8 mars), les montants ont le bon signe (les dépenses en négatif), les libellés ne sont pas
tronqués ni truffés de caractères bizarres.
Si quelque chose cloche, ouvrez **Ajuster le mappage** : séparateur, encodage, séparateur
décimal, format de date, lignes d'en-tête à ignorer, correspondance des colonnes, mode des
montants (une colonne signée, ou colonnes Débit/Crédit séparées). Vos réglages sont
enregistrés dans un **profil personnel** — le preset d'origine n'est jamais modifié.
Ensuite seulement, **Lancer l'import**.
### Étape 4 : la déduplication lors des ré-imports
C'est le point qui permet de ne pas réfléchir. **Vous pouvez ré-importer le même fichier
autant de fois que vous voulez, et importer des fichiers qui se chevauchent : aucune
transaction ne sera dupliquée.**
Deux mécanismes :
1. **Identifiant externe** — quand la source en fournit un (FITID de l'OFX, identifiant de
transaction PayPal), il fait foi. C'est le cas idéal.
2. **Empreinte de contenu** — sinon, LifeTrack calcule une empreinte à partir du compte, de
la date comptable, du montant et du libellé. Un **numéro d'occurrence** distingue les
opérations légitimement identiques (deux cafés à 2,50 € le même jour au même endroit sont
bien deux transactions, pas un doublon).
Conséquence pratique : téléchargez le relevé du mois entier chaque mois sans vous soucier des
chevauchements. Seules les nouvelles opérations seront ajoutées.
### Étape 5 : les règles de catégorisation
Une arborescence de catégories françaises complète est créée automatiquement au premier usage
(Alimentation, Logement, Transports, Santé, Loisirs, Shopping, Vape & tabac, Banque & frais,
Impôts & taxes… et côté revenus : Salaire, Aides & prestations, Remboursements, Ventes…).
Dans **Finances → Transactions**, une règle se compose de **conditions** et d'**actions** :
| Condition | Effet |
|---|---|
| **Libellé contient** | Correspondance textuelle simple — commencez toujours par là |
| **Expression régulière** | Pour les cas tordus ; une expression invalide est refusée avec un message clair |
| **Sens** | Débit, crédit, ou indifférent |
| **Montant minimum / maximum** | Fourchette |
| **Compte** | Restreindre à un compte |
Chaque règle porte une **priorité** ; on peut les réordonner. Par défaut une règle qui
correspond **arrête** l'évaluation des suivantes.
Trois garde-fous utiles :
- **Aperçu d'une règle** avant de l'enregistrer : vous voyez exactement quelles transactions
existantes elle attraperait.
- **Application en masse**, avec un choix de portée : uniquement les non catégorisées, toutes
celles qui n'ont pas été classées à la main, ou vraiment toutes.
- **Mode simulation** (`dry_run`) : LifeTrack compte ce qu'il changerait sans rien changer.
> **Vos décisions manuelles sont sacrées.** Une transaction que vous avez catégorisée
> vous-même n'est jamais réécrite par une règle, sauf demande explicite de forçage. Vous
> pouvez lancer vos règles sans crainte de perdre votre travail de tri.
Il existe aussi une **catégorisation en masse** directe, pour sélectionner plusieurs
transactions et les classer d'un coup sans écrire de règle.
### Étape 6 : les budgets
Dans **Finances → Budgets**, définissez un montant mensuel par catégorie, avec un mois de
début et éventuellement un mois de fin. LifeTrack affiche pour le mois en cours le budget, le
réalisé, le restant, le pourcentage d'avancement et une **projection de fin de mois** basée
sur votre rythme de dépense actuel — l'indicateur qui permet de corriger le tir avant le 30.
Un budget peut être révisé sans perdre son historique : la nouvelle valeur s'applique à
partir du mois que vous indiquez.
### Étape 7 : les virements internes
Quand vous déplacez 500 € de votre compte courant vers votre livret, ce n'est **pas** une
dépense. Sans traitement, ces mouvements polluent tous vos graphiques.
**Finances → Aperçu → Détecter les virements** cherche les paires de transactions qui
s'annulent : montants opposés, sur deux comptes différents, à **moins de 3 jours
d'intervalle**, avec un libellé évocateur (`VIR`, `VIREMENT`, `TRANSFERT`, `PAYPAL`). Les
paires trouvées sont classées dans la catégorie système **« Virements internes »** et exclues
des totaux de dépenses.
La détection n'est pas infaillible : vous pouvez **lier deux transactions à la main**, ou
**délier** une paire mal appariée.
### Étape 8 : les récurrents
**Finances → Récurrents** détecte tout seul vos abonnements et prélèvements réguliers, sans
que vous ayez rien à déclarer. Une série est reconnue à partir de **3 occurrences** au moins,
avec un intervalle régulier :
| Périodicité | Intervalle accepté |
|---|---|
| Hebdomadaire | 6 à 8 jours |
| Mensuelle | 25 à 35 jours |
| Trimestrielle | 85 à 97 jours |
| Annuelle | 350 à 380 jours |
Pour chaque série : le montant moyen, le montant attendu, la dernière date, la **prochaine
échéance prédite**, et si elle est toujours active. En haut de page, le **total mensuel
estimé** ramène tout à une base mensuelle (une dépense annuelle compte pour un douzième) :
c'est le montant qui part de votre compte chaque mois avant même que vous ayez décidé quoi
que ce soit.
---
## Dépannage
### « Mon fichier CSV est refusé » / la détection automatique se trompe
Dans l'ordre :
1. **Vérifiez l'extension.** LifeTrack ne se fie pas qu'au contenu : `.csv`, `.txt` pour les
relevés bancaires, `.ofx` ou `.qfx` pour l'OFX. Un fichier renommé `.xls` sera rejeté même
s'il contient du CSV.
2. **Vérifiez la taille : 20 Mio maximum.** Au-delà, découpez l'export par périodes.
3. **Choisissez le profil à la main** au lieu de « Détection automatique ». La détection
s'appuie sur les en-têtes des 4 096 premiers octets ; un export exotique ou un préambule
inhabituel peut la mettre en défaut.
4. **Ouvrez « Ajuster le mappage »** si l'aperçu est vide ou incohérent. Les suspects
habituels : le séparateur (`;` en France, `,` pour Revolut, N26 et PayPal), l'encodage
(`cp1252` pour la plupart des banques françaises, `utf-8` pour les néobanques) et le
format de date (`%d/%m/%Y` contre `%Y-%m-%d`).
5. **Message « Aucune ligne exploitable — vérifiez le profil de source »** : le fichier a bien
été lu, mais aucune ligne n'a produit de transaction. C'est presque toujours un profil
inadapté, pas un fichier corrompu.
Après l'import, le **rapport d'erreurs** est téléchargeable en CSV depuis l'historique : il
indique le numéro de ligne et le motif de chaque rejet. Les 100 premières erreurs sont
conservées.
### « J'ai des doublons »
Rappel : le ré-import du même fichier n'en crée jamais. Si vous en voyez malgré tout :
- **La même opération saisie à la main *et* importée** — la déduplication ne joue qu'entre
lignes importées. Supprimez celle de trop.
- **Deux sources décrivant le même événement** — par exemple une séance de sport arrivée à la
fois par Health Connect et par un CSV. Les séances qui se chevauchent à plus de 80 % sont
signalées comme doublons potentiels, mais elles restent deux lignes distinctes : c'est
volontaire, chaque source garde ses données.
- **Deux comptes bancaires pour le même relevé** — la déduplication est calculée **par
compte**. Importer le même fichier sur deux comptes crée bien deux jeux de transactions.
Vérifiez le compte de destination.
- **Le même relevé importé avec deux profils différents** — les libellés interprétés
différemment produisent des empreintes différentes. Annulez l'un des deux lots.
Pour les activités quotidiennes (pas, calories), il n'y a pas de doublon possible : une seule
ligne existe par jour et par source, et les sources sont fusionnées **champ par champ** selon
un ordre de priorité (saisie manuelle, puis Health Connect, puis les autres). LifeTrack
n'additionne jamais deux sources — ce serait le meilleur moyen de compter ses pas deux fois.
### « Les dates ou les heures sont décalées »
- **Une donnée apparaît la veille ou le lendemain.** Tout est stocké en UTC et réaffiché dans
votre fuseau. Vérifiez le **fuseau horaire du profil** (`Réglages → Profil`,
`Europe/Paris` par défaut). Une pesée à 00 h 30 heure française est enregistrée à 22 h 30
UTC la veille : si le fuseau est mal réglé, elle tombe le mauvais jour.
- **Un horodatage sans fuseau** (fréquent dans les exports CSV et chez Foodvisor) est
interprété en **Europe/Paris**, jamais en UTC. C'est le bon choix dans 99 % des cas, mais
cela décale les données d'un export produit dans un autre pays.
- **Un jour de plus ou de moins autour du changement d'heure** : les agrégations journalières
utilisent le fuseau, pas un décalage fixe. Un décalage résiduel sur les journées de
transition n'est pas anormal.
- **Les dates du relevé bancaire sont fausses** (le 04/03 devient le 3 avril) : mauvais format
de date dans le profil. Corrigez `date_format` dans le mappage et **annulez puis
ré-importez** le lot.
### « Module vape non configuré »
Le module refuse de calculer tant que la **référence tabac** n'est pas saisie. Allez dans
**Réglages → Vape** et renseignez au minimum la date d'arrêt, les cigarettes par jour avant
l'arrêt et le prix du paquet.
Autres cas voisins :
- **Aucun coût affiché** : il faut soit au moins une recette avec ses composants et leurs
prix, soit au moins un achat de liquide enregistré. Sans l'un ou l'autre, LifeTrack ne peut
pas connaître votre coût au ml — et préfère n'afficher aucun chiffre plutôt qu'un chiffre
faux.
- **Durée de vie des résistances à 14 jours** : c'est la valeur par défaut tant qu'il n'y a
pas eu **deux changements** enregistrés. Elle deviendra réelle dès le deuxième clic.
- **Économies qui semblent trop belles** : vérifiez la proportion de jours suivis. Si vous ne
saisissez vos recharges qu'une fois sur trois, l'imputation par la moyenne fait le reste et
la précision s'en ressent.
### Annuler un import (rollback)
Tout lot d'import est réversible.
1. Ouvrez **Imports** (ou **Finances** pour un relevé bancaire).
2. Dans **Historique des imports**, repérez la ligne concernée.
3. Cliquez sur **Annuler cet import**.
4. Confirmez : le nombre exact de lignes qui seront supprimées vous est indiqué.
Les données créées par ce lot disparaissent des tableaux de bord. **Le fichier peut être
ré-importé ensuite** — c'est la manœuvre normale quand on s'aperçoit après coup qu'on a
choisi le mauvais profil ou le mauvais compte : on annule, on corrige le mappage, on
recommence.
**Cas particulier des transactions modifiées à la main.** Si vous avez catégorisé
vous-même ou annoté des transactions du lot, LifeTrack **refuse l'annulation** et vous
avertit. Vous pouvez alors cocher **« Supprimer quand même les lignes modifiées
manuellement »** pour forcer. C'est irréversible : votre travail de catégorisation sur ces
lignes sera perdu.
### « Open Food Facts est injoignable »
Message attendu si votre serveur n'a pas d'accès Internet ou si le service est indisponible.
Ce n'est pas bloquant :
- les aliments **déjà consultés** restent disponibles dans le cache local ;
- la **saisie manuelle** fonctionne toujours ;
- réessayez plus tard, la recherche repartira sans rien perdre.
Si la recherche est simplement vide, vérifiez que vous avez tapé au moins **2 caractères**.
### « La passerelle Health Connect n'envoie rien »
Dans l'ordre :
1. **Regardez la colonne « Dernière utilisation »** dans **Réglages → Appareils & API**.
Toujours « Jamais » ? Aucune requête n'est jamais arrivée : le problème est côté réseau ou
côté application.
2. **Testez l'endpoint avec `curl`** (exemple plus haut). Si `curl` fonctionne, LifeTrack
n'est pas en cause.
3. **Vérifiez l'en-tête `X-API-Key`.** C'est la cause numéro un — voir plus haut : la
passerelle `health-connect-webhook` **ne sait pas poser d'en-tête**, elle ne peut donc pas
fonctionner en direct.
4. **Vérifiez la portée de la clé.** Une erreur 403 « Cette clé d'appareil ne dispose pas du
droit requis » signifie qu'il manque `ingest:health`.
5. **Erreur 404 « Domaine d'ingestion inconnu »** : seul le domaine `health` est traité
aujourd'hui. `POST /api/ingest/vape` et `POST /api/ingest/finance` n'existent pas encore,
même si les portées correspondantes sont proposées à la création d'une clé.
6. **Le téléphone atteint-il le serveur ?** Depuis le navigateur du téléphone, ouvrez
`http://<adresse-du-serveur>/api/healthz`. Vous devez voir `{"status":"ok"}`. Sinon, c'est
un problème de réseau local, de pare-feu ou de VPN.
7. **Économiseur de batterie** : excluez l'application des optimisations Android.
### L'application ne démarre pas du tout
```bash
docker compose ps # quels conteneurs tournent
docker compose logs api # journaux du backend
docker compose logs web # journaux de nginx
```
- **`api` redémarre en boucle** : presque toujours la base. Vérifiez que `POSTGRES_PASSWORD`
dans `.env` correspond bien à celui avec lequel le volume a été créé. Si vous avez changé
le mot de passe **après** le premier démarrage, l'ancien volume conserve l'ancien : soit
vous remettez l'ancien mot de passe, soit vous repartez de zéro avec
`docker compose down -v` (**qui efface toutes vos données**).
- **Page blanche sur `http://localhost`** : `web` tourne mais `api` non. Regardez les
journaux de `api`.
- **Port déjà utilisé** : changez `WEB_PORT` dans `.env` (par exemple `8080`) puis relancez.
- **Déconnexion permanente / boucle vers l'écran de connexion** : `LIFETRACK_JWT_SECRET` a
changé entre deux démarrages, ce qui invalide tous les jetons existants. Reconnectez-vous
une fois, et ne modifiez plus ce secret.
> Rappel : la pile Docker Compose **n'a jamais été démarrée pendant le développement** (le
> démon Docker était indisponible). Si le tout premier `docker compose up --build` bute sur
> un détail d'infrastructure, ce n'est pas anormal — commencez toujours par lire les journaux
> du conteneur fautif.
+50
View File
@@ -0,0 +1,50 @@
# Addendum — Planning de suivi (jours de pesée, séances, journal)
> Demande utilisateur : « pour le suivi calorique/poids/sport il faut un genre de planning avec
> les jours de pesée, pouvoir noter la pesée du jour, etc. »
## Concept
Un **planning hebdomadaire par habitude** (pesée, séance de sport, journal alimentaire) +
une **checklist du jour** + un **suivi d'assiduité** (streaks, calendrier, %).
Le « fait / pas fait » est **dérivé des données existantes** — aucune double saisie :
| Habitude | Jour « fait » si… |
|---|---|
| `weigh_in` (pesée) | une `WeightEntry` existe ce jour local |
| `workout` (séance) | un `Workout` existe ce jour local |
| `food_log` (journal) | ≥ 1 `FoodEntry` ce jour local |
## Modèle (module `health`)
`tracking_schedules` : `id`, `user_id`, `kind` enum (`weigh_in`|`workout`|`food_log`,
`native_enum=False`), `weekdays` JSON (liste d'entiers, 0 = lundi … 6 = dimanche),
`enabled` bool — **une ligne max par (user, kind)** (contrainte unique). Pas de table de
check-ins en v1 (dérivation ci-dessus) ; habitudes personnalisées = v2.
## API (module `health`)
- `GET /api/health/schedules` → liste des 3 plannings (avec défauts désactivés si absents) ;
`PUT /api/health/schedules/{kind}` → upsert `{weekdays, enabled}`.
- `GET /api/health/today?tz=``{ date, items: [{kind, planned, done, value}], streaks }`
`value` : poids saisi (kg) / nb de séances / kcal saisies du jour.
- `GET /api/health/stats/adherence?from&to&tz&kind=` → données prêtes pour un **calendar
heatmap ECharts** (par jour : `planned`/`done`/`missed`) + `% d'assiduité` (fait ÷ planifié),
`streak` courant et record par habitude (le streak ne compte que les jours planifiés).
## UX
- **Accueil** : carte « **Aujourd'hui** » en tête — checklist du jour : « Pesée » (✓ + valeur si
faite, sinon bouton **« Noter ma pesée »** ouvrant la modale rapide), « Séance », « Journal
alimentaire » (kcal saisies) ; badge streak « 🔥 n jours ». Les habitudes non planifiées
aujourd'hui sont grisées (« Repos »).
- **Page Poids & Objectif** : éditeur de planning (cases `L M M J V S D` + interrupteur),
carte « Pesée du jour » (planifiée / faite / manquée + CTA), **calendrier heatmap** des pesées
(vert = faite, contour = planifiée manquée), KPI « Assiduité 30 j » + streak.
- **Page Activité & Sport** : même motif pour les jours de séance planifiés.
- **Réglages → Objectif** : raccourci vers l'éditeur de planning.
## Empty states (français)
- Planning vide : « Aucun jour planifié. Choisis tes jours de pesée pour suivre ton assiduité. »
- Aujourd'hui, rien de planifié : « Rien de prévu aujourd'hui. Profites-en bien ! »
File diff suppressed because it is too large Load Diff
+953
View File
@@ -0,0 +1,953 @@
# LifeTrack — Module FINANCE : modèle de données & logique
> **Statut** : spécification prête pour implémentation — les agents d'implémentation ne feront **aucune** recherche complémentaire.
> **Stack imposée** : Python 3.12 / FastAPI / SQLAlchemy 2.0 / PostgreSQL 16 — React 18 + TS + Vite + Tailwind + Apache ECharts.
> **Conventions** : identifiants de code en **anglais**, prose et chaînes UI en **français**. Timestamps stockés en UTC (`TIMESTAMPTZ`), dates bancaires stockées telles quelles (`DATE`, dates civiles, **jamais** converties de fuseau). Fuseau d'affichage : `Europe/Paris`.
---
## 1. Vue d'ensemble
Le module FINANCE couvre :
1. **Import** de relevés bancaires (CSV banques françaises, OFX) et d'exports d'activité PayPal (CSV), via le framework générique de connecteurs/importeurs de LifeTrack (les importeurs fichiers du module FINANCE sont des implémentations du contrat commun `FileImporter`).
2. **Liste unifiée de transactions** multi-comptes, avec déduplication robuste.
3. **Catégorisation automatique** par moteur de règles rejouable.
4. **Budgets mensuels** par catégorie, avec suivi budget vs réalisé.
5. **Détection de virements internes** (exclus des statistiques de dépenses).
6. **Détection de dépenses récurrentes** (abonnements, prélèvements) avec prédiction de la prochaine échéance.
7. **Tableaux de bord** : agrégats mensuels par catégorie (avec rollup de l'arbre de catégories), cashflow, top commerçants, sankey revenus → catégories — toutes les réponses sont *chart-ready* pour ECharts.
### 1.1 Principes transverses (rappel des conventions projet)
- **Multi-utilisateur prêt** : toutes les tables métier portent `user_id` (FK vers `users.id`, table commune du socle). Toutes les requêtes filtrent par `user_id` (extrait du JWT). Aucune donnée partagée entre utilisateurs.
- **Clés primaires** : `UUID` générés côté base via `gen_random_uuid()` (extension `pgcrypto` déjà activée par le socle).
- **Horodatage** : `created_at TIMESTAMPTZ NOT NULL DEFAULT now()`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT now()` (trigger ou `onupdate` SQLAlchemy) sur toutes les tables.
- **Montants** : `NUMERIC(12,2)` **signé**. Convention : **négatif = débit/dépense, positif = crédit/revenu**. Côté Python : `decimal.Decimal` exclusivement (jamais `float`). Côté JSON : nombres à 2 décimales.
- **Devise** : `CHAR(3)` ISO 4217, défaut `'EUR'`. **v1 : aucune conversion de change** — les statistiques agrègent uniquement les transactions en EUR ; les autres devises sont listées mais exclues des agrégats (champ `excluded_foreign_currency` dans les réponses stats si pertinent).
- **Schéma SQL** : toutes les tables du module vivent dans le schéma PostgreSQL par défaut `public`, préfixées `fin_` pour éviter les collisions inter-modules (`fin_accounts`, `fin_transactions`, …). Les modèles SQLAlchemy vivent dans `api/app/modules/finance/models.py`.
### 1.2 Diagramme entités-relations
```mermaid
erDiagram
users ||--o{ fin_accounts : owns
users ||--o{ fin_categories : owns
users ||--o{ fin_rules : owns
users ||--o{ fin_budgets : owns
users ||--o{ fin_import_runs : owns
users ||--o{ fin_source_profiles : owns
fin_accounts ||--o{ fin_transactions : contains
fin_categories ||--o{ fin_categories : parent
fin_categories ||--o{ fin_transactions : categorizes
fin_categories ||--o{ fin_budgets : budgeted
fin_import_runs ||--o{ fin_transactions : imported_by
fin_source_profiles ||--o{ fin_import_runs : used_by
```
---
## 2. Modèle de données — DDL exact
Tous les `CREATE TYPE` / `CREATE TABLE` ci-dessous sont la **référence normative**. Les migrations Alembic doivent produire exactement ces structures (ordre de création : types → `fin_source_profiles``fin_accounts``fin_categories``fin_import_runs``fin_transactions``fin_rules``fin_budgets`).
### 2.0 Types énumérés
```sql
CREATE TYPE fin_account_kind AS ENUM ('checking', 'savings', 'paypal', 'cash', 'other');
CREATE TYPE fin_category_kind AS ENUM ('income', 'expense', 'transfer');
CREATE TYPE fin_source_kind AS ENUM ('csv', 'ofx', 'paypal_csv');
CREATE TYPE fin_import_status AS ENUM ('pending', 'running', 'completed', 'failed');
CREATE TYPE fin_category_source AS ENUM ('rule', 'user');
```
> SQLAlchemy : mapper via `sqlalchemy.Enum(..., name="fin_account_kind", create_type=False)` et créer les types dans la migration. Côté Python, définir des `enum.StrEnum` équivalents dans `api/app/modules/finance/enums.py` (ex. `AccountKind.CHECKING = "checking"`).
### 2.1 `fin_accounts` — comptes
```sql
CREATE TABLE fin_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- ex. "Compte courant BoursoBank"
kind fin_account_kind NOT NULL DEFAULT 'checking',
currency CHAR(3) NOT NULL DEFAULT 'EUR',
institution VARCHAR(100), -- ex. "BoursoBank", "PayPal"
iban_masked VARCHAR(34), -- ex. "FR76 **** **** **** 1234" ; jamais l'IBAN complet
initial_balance NUMERIC(12,2) NOT NULL DEFAULT 0, -- solde au point zéro (avant la 1re transaction importée)
is_archived BOOLEAN NOT NULL DEFAULT FALSE, -- masqué des filtres par défaut, données conservées
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_fin_accounts_user_name UNIQUE (user_id, name)
);
CREATE INDEX ix_fin_accounts_user ON fin_accounts(user_id);
```
Notes :
- **Ne jamais stocker l'IBAN complet.** Si un import OFX fournit un numéro de compte, le masquer avant stockage (`****` + 4 derniers caractères).
- `initial_balance` permet de calculer un solde courant : `initial_balance + SUM(amount)`. Le solde n'est pas stocké, il est calculé.
- La suppression d'un compte est **refusée** (HTTP 409) s'il contient des transactions ; proposer l'archivage à la place. (`ON DELETE CASCADE` existe en base par sécurité, mais l'API bloque en amont.)
### 2.2 `fin_categories` — catégories (arbre)
```sql
CREATE TABLE fin_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id UUID REFERENCES fin_categories(id) ON DELETE CASCADE, -- NULL = catégorie racine
name VARCHAR(80) NOT NULL, -- ex. "Alimentation", "Courses"
icon VARCHAR(50), -- nom d'icône (set lucide-react), ex. "shopping-cart"
color CHAR(7), -- hex "#RRGGBB", hérite du parent si NULL
kind fin_category_kind NOT NULL DEFAULT 'expense',
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- catégories créées au seed, non supprimables
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_fin_categories_sibling UNIQUE (user_id, parent_id, name)
);
CREATE INDEX ix_fin_categories_user ON fin_categories(user_id);
CREATE INDEX ix_fin_categories_parent ON fin_categories(parent_id);
```
Règles métier (validées côté API, pas en base) :
- **Profondeur maximale : 2 niveaux** (racine + enfants). Refuser la création d'un enfant sous une catégorie qui a elle-même un parent (HTTP 422).
- Un enfant hérite du `kind` de son parent (forcé à la création/édition).
- Il existe **une seule** catégorie de `kind = 'transfer'` par utilisateur : la catégorie système **« Virements internes »** (créée au seed, `is_system = TRUE`). Le moteur de virements l'utilise ; interdire création/suppression d'autres catégories `transfer`.
- La suppression d'une catégorie remet à `NULL` le `category_id` des transactions concernées (fait explicitement par l'API : `UPDATE fin_transactions SET category_id = NULL, category_source = NULL WHERE category_id IN (...)` avant le `DELETE`), et supprime les budgets et met à `NULL` les actions de règles qui la référencent.
#### Seed par défaut (créé par le wizard de premier lancement pour chaque utilisateur)
Racines `expense` : Alimentation (enfants : Courses, Restaurants & bars, Livraison), Logement (Loyer/Crédit, Énergie, Eau, Internet & mobile, Assurance habitation, Entretien), Transport (Carburant, Péages & parking, Transports en commun, Entretien véhicule, Assurance auto), Santé (Pharmacie, Médecin, Mutuelle), Loisirs (Abonnements & streaming, Jeux vidéo, Sorties, Sport, Vacances), Shopping (Vêtements, High-tech, Maison), Vape & tabac, Banque & frais (Frais bancaires, Intérêts), Impôts & taxes, Enfants & famille, Animaux, Dons & cadeaux, Autres dépenses.
Racines `income` : Salaire, Aides & prestations (CAF, etc.), Remboursements (Santé, Autres), Ventes (Leboncoin, Vinted…), Intérêts & placements, Autres revenus.
Racine `transfer` (système) : **Virements internes**.
Chaque racine du seed reçoit une couleur distincte de la palette du design system et un `icon` ; les enfants héritent (`color = NULL`).
### 2.3 `fin_source_profiles` — profils de source d'import
```sql
CREATE TABLE fin_source_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL = preset intégré (built-in), visible par tous
name VARCHAR(100) NOT NULL, -- ex. "BoursoBank CSV", "PayPal (rapport d'activité)"
kind fin_source_kind NOT NULL,
config JSONB NOT NULL DEFAULT '{}', -- voir §3 pour le schéma exact par kind
is_builtin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ck_fin_source_profiles_builtin CHECK ((is_builtin AND user_id IS NULL) OR (NOT is_builtin AND user_id IS NOT NULL))
);
CREATE UNIQUE INDEX uq_fin_source_profiles_builtin_name ON fin_source_profiles(name) WHERE user_id IS NULL;
CREATE UNIQUE INDEX uq_fin_source_profiles_user_name ON fin_source_profiles(user_id, name) WHERE user_id IS NOT NULL;
```
- Les presets intégrés (§3.4) sont insérés par une migration de données Alembic. Ils sont **en lecture seule** via l'API ; l'utilisateur peut les **cloner** pour les personnaliser (endpoint `POST /source-profiles/{id}/clone`).
### 2.4 `fin_import_runs` — exécutions d'import
```sql
CREATE TABLE fin_import_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source_profile_id UUID REFERENCES fin_source_profiles(id) ON DELETE SET NULL,
account_id UUID NOT NULL REFERENCES fin_accounts(id) ON DELETE CASCADE, -- compte cible choisi à l'import
filename VARCHAR(255) NOT NULL,
file_sha256 CHAR(64) NOT NULL, -- hash du fichier brut : avertir si fichier déjà importé à l'identique
status fin_import_status NOT NULL DEFAULT 'pending',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
stats JSONB NOT NULL DEFAULT '{}',
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_fin_import_runs_user_started ON fin_import_runs(user_id, started_at DESC);
```
Forme exacte de `stats` (renseignée à la fin du run) :
```json
{
"rows_total": 143,
"rows_imported": 120,
"rows_skipped_duplicate": 21,
"rows_skipped_filtered": 1,
"rows_error": 1,
"rules_applied": 97,
"transfers_detected": 2,
"date_min": "2026-06-01",
"date_max": "2026-07-31",
"errors": [ { "row": 57, "message": "Date invalide : '32/06/2026'" } ]
}
```
- `rows_skipped_filtered` : lignes volontairement ignorées par le parseur (ex. lignes PayPal de type `Authorization`, voir §3.3).
- `errors` est plafonné à 50 entrées (au-delà : `"errors_truncated": true`).
- **Rollback d'un import** : `DELETE /imports/{id}` supprime les transactions liées (`import_run_id = id`) puis le run. Refusé (409) si une transaction du run a été modifiée manuellement (`category_source = 'user'` ou notes non nulles) sauf si `?force=true`.
### 2.5 `fin_transactions` — transactions
```sql
CREATE TABLE fin_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES fin_accounts(id) ON DELETE CASCADE,
booked_date DATE NOT NULL, -- date comptable (celle du relevé)
value_date DATE, -- date de valeur si fournie
amount NUMERIC(12,2) NOT NULL, -- signé : négatif = débit, positif = crédit
currency CHAR(3) NOT NULL DEFAULT 'EUR',
label_raw TEXT NOT NULL, -- libellé brut d'origine, jamais modifié
label_clean TEXT NOT NULL, -- libellé lisible ; initialisé = normalisation légère de label_raw,
-- modifiable par règle ou par l'utilisateur
counterparty VARCHAR(150), -- commerçant/tiers si identifiable (règle ou saisie)
category_id UUID REFERENCES fin_categories(id) ON DELETE SET NULL,
category_source fin_category_source, -- 'rule' | 'user' | NULL (non catégorisé)
applied_rule_id UUID, -- FK logique vers fin_rules.id (pas de contrainte : règle supprimable)
notes TEXT,
import_run_id UUID REFERENCES fin_import_runs(id) ON DELETE SET NULL, -- NULL = saisie manuelle
external_id VARCHAR(255), -- FITID OFX ou Transaction ID PayPal
dedup_hash CHAR(64) NOT NULL, -- sha256 hex, voir §4.3
transfer_group_id UUID, -- deux jambes d'un virement interne partagent ce UUID
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_fin_transactions_dedup UNIQUE (account_id, dedup_hash)
);
CREATE UNIQUE INDEX uq_fin_transactions_external
ON fin_transactions(account_id, external_id) WHERE external_id IS NOT NULL;
CREATE INDEX ix_fin_transactions_user_date ON fin_transactions(user_id, booked_date DESC);
CREATE INDEX ix_fin_transactions_account_date ON fin_transactions(account_id, booked_date DESC);
CREATE INDEX ix_fin_transactions_category ON fin_transactions(category_id);
CREATE INDEX ix_fin_transactions_transfer ON fin_transactions(transfer_group_id) WHERE transfer_group_id IS NOT NULL;
CREATE INDEX ix_fin_transactions_label_trgm ON fin_transactions USING gin (label_clean gin_trgm_ops); -- extension pg_trgm
```
Notes :
- Activer l'extension `pg_trgm` dans la migration (`CREATE EXTENSION IF NOT EXISTS pg_trgm;`) pour la recherche plein-texte approximative sur `label_clean` (`ILIKE '%...%'` performant).
- `applied_rule_id` est informatif (debug/traçabilité) ; volontairement **sans** contrainte FK pour que la suppression d'une règle ne touche pas aux transactions.
- Saisie manuelle : `import_run_id = NULL`, `dedup_hash` calculé quand même (mêmes règles §4.3) pour protéger d'un doublon avec un import futur, `external_id = NULL`.
- Une transaction avec `transfer_group_id IS NOT NULL` a toujours `category_id` = catégorie système « Virements internes » et `category_source` conservé tel quel (`rule`/`user`/NULL selon origine du marquage).
### 2.6 `fin_rules` — règles de catégorisation
```sql
CREATE TABLE fin_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- ex. "Courses Carrefour"
priority INTEGER NOT NULL DEFAULT 100, -- croissant = évalué en premier (1 avant 100)
enabled BOOLEAN NOT NULL DEFAULT TRUE,
stop BOOLEAN NOT NULL DEFAULT TRUE, -- TRUE : arrêt après application ; FALSE : les règles suivantes continuent
matchers JSONB NOT NULL, -- schéma §5.1
actions JSONB NOT NULL, -- schéma §5.2
hit_count INTEGER NOT NULL DEFAULT 0, -- compteur cumulé d'applications (informatif)
last_applied_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_fin_rules_user_priority ON fin_rules(user_id, priority, created_at);
```
Ordre d'évaluation déterministe : `ORDER BY priority ASC, created_at ASC, id ASC`.
### 2.7 `fin_budgets` — budgets mensuels
```sql
CREATE TABLE fin_budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES fin_categories(id) ON DELETE CASCADE,
monthly_amount NUMERIC(12,2) NOT NULL CHECK (monthly_amount > 0), -- toujours positif (plafond de dépense)
start_month DATE NOT NULL, -- toujours le 1er du mois, ex. '2026-01-01' ; CHECK ci-dessous
end_month DATE, -- 1er du dernier mois inclus ; NULL = sans fin
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ck_fin_budgets_first_of_month CHECK (date_trunc('month', start_month) = start_month
AND (end_month IS NULL OR date_trunc('month', end_month) = end_month)),
CONSTRAINT ck_fin_budgets_range CHECK (end_month IS NULL OR end_month >= start_month)
);
CREATE INDEX ix_fin_budgets_user_cat ON fin_budgets(user_id, category_id);
```
Règles métier :
- Un budget cible une catégorie `kind = 'expense'` uniquement (validation API).
- **Non-chevauchement** : pour une même `category_id`, les périodes `[start_month, end_month]` ne doivent pas se chevaucher. Validé côté API à la création/édition (requête d'intersection ; HTTP 409 si conflit). Changer le montant d'un budget « à partir du mois M » = clore l'ancien (`end_month = M - 1 mois`) + créer le nouveau (`start_month = M`) — l'API `PUT` propose ce comportement via le paramètre `effective_from`.
- Un budget sur une catégorie **racine** couvre le rollup de tout son sous-arbre ; un budget sur un **enfant** ne couvre que lui. Si les deux existent, ils sont affichés tous les deux (le budget racine inclut la consommation de l'enfant — documenté dans l'UI).
---
## 3. Profils de source : schémas `config` et presets intégrés
### 3.1 `kind = 'csv'` — CSV générique à mapping configurable
Schéma JSON complet de `config` (toutes clés présentes ; valeurs par défaut indiquées) :
```json
{
"encoding": "cp1252", // "utf-8" | "utf-8-sig" | "cp1252" | "iso-8859-15" | "auto" (voir §4.1)
"delimiter": ";", // ";" | "," | "\t" | "|"
"quote_char": "\"",
"decimal_separator": ",", // "," | "."
"thousands_separator": " ", // "" | " " | "." | " " (espace insécable, fréquent dans les exports FR)
"date_format": "%d/%m/%Y", // format strptime Python
"has_header": true,
"skip_rows_top": 0, // lignes de préambule à ignorer AVANT l'en-tête (relevés CA/LBP)
"skip_rows_bottom": 0, // lignes de pied (totaux) à ignorer
"columns": {
// Chaque valeur est SOIT un nom de colonne d'en-tête (string), SOIT un index 0-based (int) si has_header=false.
"booked_date": "dateOp", // OBLIGATOIRE
"value_date": "dateVal", // optionnel -> null
"label": "label", // OBLIGATOIRE
"amount": "amount", // mode "signed" : obligatoire ; sinon null
"debit": null, // mode "split" : colonne débit (valeurs positives ou déjà négatives)
"credit": null, // mode "split" : colonne crédit
"currency": null, // optionnel ; sinon devise du compte cible
"external_id": null // optionnel (rare en CSV bancaire)
},
"amount_mode": "signed", // "signed" (une colonne signée) | "split" (colonnes débit/crédit séparées)
"invert_sign": false, // true si la banque exporte les débits en positif dans la colonne signée
"label_join": [], // colonnes supplémentaires concaténées au label (ex. ["category","supplierFound"]) — " — " comme séparateur
"skip_row_if": [] // filtres d'exclusion : [{"column": "type", "equals": "Solde"}]
}
```
Règles de parsing :
- Mode `split` : `amount = credit - abs(debit)` ; une ligne doit avoir exactement une des deux colonnes non vide, sinon `rows_error`.
- Montants : retirer `thousands_separator` et les espaces, remplacer `decimal_separator` par `.`, retirer un éventuel symbole `€` ou `EUR`, parser en `Decimal`. Gérer le signe unicode `` (U+2212) comme `-`.
- Dates : parser avec `datetime.strptime(value.strip(), date_format).date()`. Échec → ligne en erreur (l'import continue).
- Résolution des colonnes par nom : insensible à la casse et aux accents, espaces trimés.
### 3.2 `kind = 'ofx'` — OFX (Open Financial Exchange)
`config` :
```json
{
"fallback_encoding": "cp1252", // utilisé si l'en-tête OFX ne déclare pas de charset exploitable
"account_match": null // optionnel : si le fichier contient plusieurs comptes, ACCTID à retenir ; null = premier compte + avertissement
}
```
Spécification de parsing (l'implémentation utilise la bibliothèque **`ofxtools`** ; si le fichier la fait échouer, fallback sur un parseur tolérant par expressions régulières décrit ci-dessous) :
- **OFX 1.x (SGML)** : en-tête texte `OFXHEADER:100 ... ENCODING:USASCII / CHARSET:1252`. Les banques françaises livrent quasi systématiquement du **cp1252**. Décoder selon `CHARSET` si présent, sinon `fallback_encoding`.
- **OFX 2.x (XML)** : prologue XML standard, encodage déclaré dans le prologue.
- Champs extraits par transaction (bloc `<STMTTRN>`) :
- `DTPOSTED``booked_date`. Format `YYYYMMDD` éventuellement suivi de `HHMMSS[.XXX][gmt offset]` — ne garder que les 8 premiers chiffres, **sans conversion de fuseau**.
- `TRNAMT``amount` (point décimal, déjà signé — négatif = débit).
- `FITID``external_id` (déduplication prioritaire, §4.3).
- `NAME` + `MEMO``label_raw = NAME` si `MEMO` vide, sinon `NAME + " — " + MEMO` (si `NAME` absent : `MEMO` seul).
- `TRNTYPE` : ignoré pour le montant (déjà signé), mais concaténé nulle part ; conservé uniquement si besoin futur.
- `CURDEF` du relevé → `currency`.
- Fallback regex (OFX 1.x mal formé, très courant) : découper sur `<STMTTRN>`, extraire chaque champ par `r"<FIELD>([^<\r\n]*)"`. Tolérer l'absence de balises fermantes (SGML).
### 3.3 `kind = 'paypal_csv'` — export d'activité PayPal
PayPal (paypal.com → Activité → Télécharger → « Tous les types de transactions », CSV). Colonnes du rapport FR (l'ordre peut varier, résoudre **par nom d'en-tête**, insensible casse/accents) : `Date`, `Heure`, `Fuseau horaire`, `Nom`, `Type`, `État`, `Devise`, `Brut`, `Frais`, `Net`, `Adresse email de l'expéditeur`, `Adresse email du destinataire`, `Numéro de transaction`, `Titre de l'objet`, `Numéro de la transaction de référence`, `Solde`, ...
Le rapport EN utilise : `Date`, `Time`, `TimeZone`, `Name`, `Type`, `Status`, `Currency`, `Gross`, `Fee`, `Net`, `From Email Address`, `To Email Address`, `Transaction ID`, `Item Title`, `Reference Txn ID`, `Balance`. Le parseur mappe les **deux** jeux d'en-têtes (dictionnaire d'alias intégré au code).
`config` :
```json
{
"encoding": "utf-8-sig", // PayPal exporte en UTF-8 avec BOM
"delimiter": ",",
"decimal_separator": ",", // export FR : virgule ; export EN : point — "auto" essaie "," puis "."
"date_format": "%d/%m/%Y",
"use_net_amount": true, // montant = Net (Brut - Frais) ; false = Brut
"skip_types": [
"Autorisation", "Authorization",
"Commande", "Order",
"Annulation d'autorisation", "Void of Authorization",
"Retenue pour vérification par PayPal", "Payment Review Hold",
"Annulation de la retenue", "Payment Review Release"
],
"skip_status_not_completed": true, // ne garder que État = "Effectué"/"Completed"
"conversion_as_skip": true // lignes "Conversion de devise générale"/"General Currency Conversion" ignorées (comptées en rows_skipped_filtered)
}
```
Règles spécifiques :
- `Numéro de transaction` / `Transaction ID``external_id` (**toujours présent et unique** chez PayPal : c'est la clé de dédup prioritaire).
- `label_raw = Nom + " — " + Type` (+ `" — " + Titre de l'objet` si non vide). `counterparty` = `Nom` directement.
- `amount` = colonne `Net` (signée par PayPal : paiement envoyé = négatif). `currency` = colonne `Devise`.
- Les paires de conversion de devise (deux lignes, une par devise) sont ignorées si `conversion_as_skip = true` — sinon elles créeraient un faux revenu et une fausse dépense. Le montant réellement débité apparaît sur la ligne de paiement principale.
- Le compte cible d'un import PayPal est typiquement un compte `kind = 'paypal'`. Le rechargement PayPal depuis la banque apparaîtra des deux côtés → détecté comme virement interne (§6).
### 3.4 Presets intégrés (migration de données)
8 lignes insérées avec `user_id = NULL, is_builtin = TRUE` :
| `name` | `kind` | Particularités du `config` |
|---|---|---|
| `CSV générique` | `csv` | Le config de §3.1 tel quel (valeurs par défaut) ; l'UI d'import propose un « aperçu + mapping » basé sur ce preset. |
| `BoursoBank / Boursorama (CSV)` | `csv` | `delimiter=";"`, `encoding="utf-8-sig"`, `date_format="%Y-%m-%d"`, colonnes : `booked_date="dateOp"`, `value_date="dateVal"`, `label="label"`, `amount="amount"`, `amount_mode="signed"`, `decimal_separator=","`, `label_join=["supplierFound"]`. |
| `Crédit Agricole (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=9` (préambule de relevé), `amount_mode="split"`, colonnes : `booked_date="Date"`, `label="Libellé"`, `debit="Débit euros"`, `credit="Crédit euros"`, `decimal_separator=","`, `thousands_separator=" "`. |
| `La Banque Postale (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=6`, `amount_mode="signed"`, colonnes : `booked_date="Date"`, `label="Libellé"`, `amount="Montant(EUROS)"`, `decimal_separator=","`. |
| `Société Générale (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=2`, `amount_mode="signed"`, colonnes : `booked_date="Date de l'opération"` (alias `"Date"`), `label="Libellé"` (alias `"Détail de l'écriture"`), `amount="Montant de l'opération"` (alias `"Montant"`), `decimal_separator=","`. |
| `Fortuneo (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `amount_mode="split"`, colonnes : `booked_date="Date opération"`, `value_date="Date valeur"`, `label="Libellé"`, `debit="Débit"`, `credit="Crédit"`, `decimal_separator=","`. |
| `OFX (toutes banques)` | `ofx` | Config §3.2 par défaut. À privilégier quand la banque propose l'OFX (dédup via FITID). |
| `PayPal — rapport d'activité (CSV)` | `paypal_csv` | Config §3.3 par défaut. |
> **Important pour l'implémentation** : les layouts CSV des banques changent régulièrement. Ces presets sont des **valeurs de départ raisonnables** ; l'écran d'import doit TOUJOURS afficher un aperçu des 20 premières lignes parsées (dates, montants, libellés) avant confirmation, et permettre d'ajuster le mapping (ce qui clone le preset en profil utilisateur). Les alias de noms de colonnes indiqués ci-dessus sont mis dans le config sous forme de listes : toute valeur de `columns.*` peut être `string | int | string[]` (première colonne trouvée dans l'en-tête).
---
## 4. Pipeline d'import — logique détaillée
Module : `api/app/modules/finance/importer/`. Point d'entrée : `run_import(user, account, profile, file_bytes, filename) -> ImportRun`. Exécution **synchrone** dans la requête HTTP (fichiers < 5 Mo, quelques milliers de lignes : < 2 s) ; statut `running``completed`/`failed`. Limite upload : 20 Mo (HTTP 413 au-delà).
### 4.1 Étape 1 — Décodage
```python
def decode_bytes(raw: bytes, encoding_cfg: str) -> str:
if raw.startswith(b"\xef\xbb\xbf"):
return raw.decode("utf-8-sig")
if encoding_cfg != "auto":
return raw.decode(encoding_cfg, errors="replace")
# mode "auto" : utf-8 strict d'abord, sinon cp1252 (jamais d'échec : cp1252 décode tout octet)
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("cp1252")
```
### 4.2 Étapes 23 — Parsing + normalisation
Chaque parseur (`CsvParser`, `OfxParser`, `PaypalCsvParser` — sélectionné par `profile.kind`) produit une liste de `NormalizedRow` :
```python
@dataclass
class NormalizedRow:
booked_date: date
value_date: date | None
amount: Decimal # signé, quantifié à 2 décimales : amount.quantize(Decimal("0.01"), ROUND_HALF_UP)
currency: str # ISO 4217 upper ; défaut = account.currency
label_raw: str # brut, trimé, sauts de ligne remplacés par un espace
counterparty: str | None # renseigné uniquement par le parseur PayPal
external_id: str | None
source_row_index: int # index 1-based dans le fichier (pour les messages d'erreur)
```
Les lignes invalides (date/montant imparsables) sont collectées dans `stats.errors` **sans interrompre** l'import. Si `rows_error == rows_total` (aucune ligne valide), le run passe en `failed` avec `error_message = "Aucune ligne exploitable — vérifiez le profil de source."`.
### 4.3 Étape 4 — Normalisation du libellé et `dedup_hash`
Deux normalisations distinctes, dans `importer/normalize.py` :
```python
def normalize_label_for_hash(label: str) -> str:
"""Normalisation STABLE et CONSERVATRICE : utilisée pour le hash de dédup.
Ne retire aucune information variable, sinon deux transactions distinctes
fusionneraient. Uppercase + accents retirés + espaces normalisés, rien d'autre."""
s = unicodedata.normalize("NFKD", label)
s = "".join(c for c in s if not unicodedata.combining(c)) # é -> e
s = s.upper()
s = re.sub(r"\s+", " ", s).strip()
return s
def compute_dedup_hash(account_id: UUID, booked_date: date, amount: Decimal,
label_raw: str, occurrence: int) -> str:
canonical = "|".join([
str(account_id),
booked_date.isoformat(), # "2026-08-13"
f"{amount:.2f}", # "-12.50" (signe inclus)
normalize_label_for_hash(label_raw),
str(occurrence), # rang parmi les lignes identiques du MÊME fichier (0,1,2…)
])
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
```
- **`occurrence`** résout le cas réel de deux transactions identiques le même jour (deux cafés à 2,50 €) : dans un même fichier, les lignes de tuple `(booked_date, amount, label_norm)` identique sont numérotées 0, 1, 2… dans l'ordre du fichier. Les exports bancaires étant cumulatifs et ordonnés, un ré-import chevauchant reproduit les mêmes rangs → dédup correcte, sans perdre de vraie transaction dupliquée.
- Le hash inclut `account_id` par défense en profondeur, même si la contrainte est déjà scopée compte.
- `label_clean` initial = normalisation **légère** de `label_raw` : trim + espaces collapsés + suppression des préfixes bancaires purement techniques via la regex `^(CARTE \d{2}/\d{2}(/\d{2,4})? |PAIEMENT (PSC |CB )?\d{4} |PRLV SEPA |VIR SEPA |VIR INST |ACHAT CB )` (insensible à la casse). Le reste est conservé tel quel — les règles et l'utilisateur affinent ensuite.
### 4.4 Étapes 57 — Dédup, règles, insertion (pseudocode complet)
```python
def run_import(user, account, profile, file_bytes, filename) -> ImportRun:
run = create_import_run(user, account, profile, filename,
file_sha256=sha256(file_bytes), status="running")
# Avertissement fichier identique (non bloquant) :
# si un run 'completed' du même user a le même file_sha256 -> stats["duplicate_file_of"] = run_id
text = decode_bytes(file_bytes, profile.config.get("encoding", "auto"))
rows = PARSERS[profile.kind](profile.config, text, account).parse() # list[NormalizedRow] + erreurs
# -- numérotation des occurrences intra-fichier --
counter: dict[tuple, int] = defaultdict(int)
prepared = []
for row in rows:
key = (row.booked_date, row.amount, normalize_label_for_hash(row.label_raw))
occ = counter[key]; counter[key] += 1
prepared.append((row, compute_dedup_hash(account.id, row.booked_date,
row.amount, row.label_raw, occ)))
# -- pré-chargement des doublons existants (1 requête, pas N) --
dmin, dmax = min(r.booked_date for r, _ in prepared), max(r.booked_date for r, _ in prepared)
existing_hashes = set(select fin_transactions.dedup_hash
where account_id = account.id and booked_date between dmin and dmax)
existing_ext = set(select external_id ... where external_id is not null
and account_id = account.id) # sans borne de date : FITID est global au compte
rules = load_enabled_rules(user) # ORDER BY priority, created_at, id
to_insert, skipped = [], 0
for row, dhash in prepared:
if (row.external_id and row.external_id in existing_ext) or dhash in existing_hashes:
skipped += 1
continue
existing_hashes.add(dhash) # protège aussi des doublons intra-fichier après occurrence
tx = build_transaction(user, account, row, dhash, run,
label_clean=light_clean(row.label_raw))
apply_rules_to_tx(tx, rules) # §5.3 — mutation en mémoire avant insert
to_insert.append(tx)
bulk_insert(to_insert) # une seule transaction SQL pour tout le run
transfers = detect_transfers(user, date_min=dmin - timedelta(days=3),
date_max=dmax + timedelta(days=3)) # §6
finalize_run(run, stats={...}, status="completed")
return run
```
Garanties :
- **Idempotence** : ré-importer le même fichier (ou un export chevauchant) n'insère aucun doublon.
- **Atomicité** : l'insertion des transactions du run est une transaction SQL unique ; en cas d'exception, rollback complet et `status = 'failed'`.
- La détection de virements est relancée automatiquement sur la fenêtre de dates importée (± 3 jours).
---
## 5. Moteur de règles
### 5.1 Schéma JSON de `matchers`
Toutes les conditions présentes sont **combinées en ET**. Clés absentes ou `null` = non contraint. Au moins une clé non nulle exigée (validation API).
```json
{
"label_contains": ["CARREFOUR", "CRF "], // OU logique entre les éléments ; comparaison sur
// normalize_label_for_hash(label_raw) ET sur label_clean normalisé
// (insensible casse/accents) ; match par sous-chaîne
"label_regex": "^CB\\s+CARREFOUR\\b", // regex Python (module re), flags IGNORECASE appliqué,
// testée sur label_raw puis label_clean ; regex invalide = 422 à la sauvegarde
"amount_min": -200.00, // borne inférieure INCLUSIVE sur le montant SIGNÉ
"amount_max": -0.01, // borne supérieure INCLUSIVE sur le montant SIGNÉ
"direction": "debit", // "debit" (amount < 0) | "credit" (amount > 0) | "any" (défaut)
"account_id": null // UUID : restreint la règle à un compte
}
```
> Note UX : l'UI présente `amount_min`/`amount_max` en valeur absolue avec le sélecteur `direction` ; l'API stocke les bornes signées telles quelles.
### 5.2 Schéma JSON de `actions`
Au moins une clé non nulle exigée.
```json
{
"set_category_id": "d290f1ee-...", // UUID d'une catégorie de l'utilisateur (validé à la sauvegarde)
"set_label_clean": "Carrefour", // remplace label_clean
"set_counterparty": "Carrefour", // remplace counterparty
"mark_transfer": false // true : marque la transaction comme virement interne
// (catégorie forcée = « Virements internes » ; transfer_group_id reste NULL
// jusqu'à appariement par le détecteur §6 — jambe « orpheline » acceptée)
}
```
### 5.3 Application — pseudocode
```python
def rule_matches(rule, tx) -> bool:
m = rule.matchers
if m.get("account_id") and str(tx.account_id) != m["account_id"]: return False
if m.get("direction") == "debit" and tx.amount >= 0: return False
if m.get("direction") == "credit" and tx.amount <= 0: return False
if m.get("amount_min") is not None and tx.amount < Decimal(str(m["amount_min"])): return False
if m.get("amount_max") is not None and tx.amount > Decimal(str(m["amount_max"])): return False
if m.get("label_contains"):
hay = normalize_label_for_hash(tx.label_raw) + " | " + normalize_label_for_hash(tx.label_clean)
if not any(normalize_label_for_hash(n) in hay for n in m["label_contains"]): return False
if m.get("label_regex"):
rx = compiled_regex_cache[rule.id] # re.compile(pattern, re.IGNORECASE) mis en cache
if not (rx.search(tx.label_raw) or rx.search(tx.label_clean)): return False
return True
def apply_rules_to_tx(tx, rules) -> None:
"""Ne touche JAMAIS une catégorisation manuelle (category_source == 'user'),
sauf mode force explicite (§5.4)."""
for rule in rules: # déjà triées par priorité
if not rule_matches(rule, tx): continue
a = rule.actions
if a.get("set_category_id") and tx.category_source != "user":
tx.category_id, tx.category_source, tx.applied_rule_id = a["set_category_id"], "rule", rule.id
if a.get("set_label_clean"): tx.label_clean = a["set_label_clean"]
if a.get("set_counterparty"): tx.counterparty = a["set_counterparty"]
if a.get("mark_transfer") and tx.category_source != "user":
tx.category_id, tx.category_source = transfer_category_id(tx.user_id), "rule"
rule.hit_count += 1
if rule.stop: break
```
### 5.4 Ré-exécution à la demande (`POST /rules/apply`)
Paramètres : `scope` (`"uncategorized"` par défaut — uniquement `category_id IS NULL` ; `"all_non_manual"` — tout sauf `category_source = 'user'` ; `"all"` — tout, **écrase** même le manuel, protégé par `force: true` obligatoire), `date_from`/`date_to` optionnels, `account_id` optionnel, `rule_id` optionnel (tester une seule règle), `dry_run` (défaut `false`).
Traitement par lots de 1 000 transactions (streaming SQLAlchemy `yield_per`), réponse :
```json
{ "scanned": 4210, "matched": 1830, "updated": 1790, "dry_run": false,
"by_rule": [ { "rule_id": "…", "name": "Courses Carrefour", "matched": 240 } ] }
```
En `dry_run`, rien n'est écrit (rollback), les compteurs sont retournés à l'identique.
---
## 6. Détection de virements internes
Service `detect_transfers(user, date_min=None, date_max=None) -> int` (nombre de paires créées). Lancé : automatiquement en fin d'import (fenêtre du fichier ± 3 jours), et à la demande (`POST /transfers/detect`).
```python
def detect_transfers(user, date_min=None, date_max=None) -> int:
# Candidats : transactions non encore appariées, comptes de l'utilisateur, même devise
txs = load(user, transfer_group_id=None, date_between=(date_min, date_max))
debits = [t for t in txs if t.amount < 0]
credits_by_amount = index_by(lambda t: -t.amount, [t for t in txs if t.amount > 0])
pairs = []
for d in debits:
for c in credits_by_amount.get(-d.amount, []):
if c.account_id == d.account_id: continue # même compte : pas un virement
if c.currency != d.currency: continue
delta = abs((c.booked_date - d.booked_date).days)
if delta > 3: continue # tolérance ±3 jours
score = (3 - delta) * 10 # proximité de date d'abord
joined = normalize_label_for_hash(d.label_raw + " " + c.label_raw)
if re.search(r"\bVIR(EMENT)?\b|\bVIRT\b|TRANSFERT|PAYPAL", joined): score += 5
pairs.append((score, d, c))
pairs.sort(key=lambda p: (-p[0], p[1].booked_date)) # gloutonne : meilleurs scores d'abord
used, created = set(), 0
for score, d, c in pairs:
if d.id in used or c.id in used: continue
gid = uuid4()
for t in (d, c):
t.transfer_group_id = gid
if t.category_source != "user": # ne pas écraser un choix manuel
t.category_id, t.category_source = transfer_category_id(user.id), "rule"
used |= {d.id, c.id}; created += 1
return created
```
Règles associées :
- **Exclusion des statistiques** : toute requête d'agrégat de dépenses/revenus (§8) filtre `transfer_group_id IS NULL AND (category IS NULL OR category.kind <> 'transfer')`. Les virements restent visibles dans la liste des transactions (badge « Virement interne »).
- **Liaison manuelle** : `POST /transfers/link {transaction_id_a, transaction_id_b}` — valide montants opposés (sinon 422 avec message explicite ; tolérance zéro sur le montant), comptes différents ; crée le groupe.
- **Déliaison** : `DELETE /transfers/{transfer_group_id}` — remet `transfer_group_id = NULL` sur les deux jambes et `category_id = NULL, category_source = NULL` si la catégorie était « Virements internes » posée par `rule`.
- Une jambe peut rester orpheline (ex. compte PayPal pas encore importé) : marquée transfer par règle (§5.2 `mark_transfer`), elle sera appariée au prochain `detect_transfers`.
---
## 7. Détection de récurrences
Service **calculé à la volée** (pas de table dédiée en v1 ; si le temps de calcul dépasse ~200 ms sur des volumes réels, ajouter une table de cache `fin_recurring_series` recalculée après chaque import — hors périmètre v1). Module : `finance/recurring.py`.
### 7.1 Clé de regroupement (`merchant_key`)
Normalisation **agressive**, distincte de celle du hash :
```python
def merchant_key(label_raw: str, counterparty: str | None) -> str:
if counterparty:
return normalize_label_for_hash(counterparty)
s = normalize_label_for_hash(label_raw)
s = re.sub(r"\b\d{2}/\d{2}(/\d{2,4})?\b", "", s) # dates dans le libellé
s = re.sub(r"\b\d{4,}\b", "", s) # numéros (carte, référence, facture)
s = re.sub(r"\b(CB|CARTE|PRLV|SEPA|VIR|ECH|PAIEMENT|ACHAT|WEB|FACT(URE)?)\b", "", s)
s = re.sub(r"[^A-Z0-9 ]", " ", s)
s = re.sub(r"\s+", " ", s).strip()
return s[:60]
```
### 7.2 Algorithme
```python
PERIODICITIES = [ # (nom, intervalle_min_jours, intervalle_max_jours, intervalle_nominal)
("weekly", 6, 8, 7),
("monthly", 25, 35, 30), # tolérance : prélèvements glissant autour du même jour du mois
("quarterly",85, 97, 91),
("yearly", 350, 380, 365),
]
def detect_recurring(user, lookback_months=18) -> list[RecurringSeries]:
txs = load_expenses(user, since=today - relativedelta(months=lookback_months),
exclude_transfers=True) # dépenses uniquement (amount < 0)
groups = group_by(lambda t: (merchant_key(t.label_raw, t.counterparty)), txs)
series = []
for key, items in groups.items():
if len(items) < 3 or not key: continue
items.sort(key=lambda t: t.booked_date)
# dédoublonner les jours multiples (2 achats même jour même commerçant = 1 occurrence datée)
dates = sorted({t.booked_date for t in items})
if len(dates) < 3: continue
intervals = [ (b - a).days for a, b in zip(dates, dates[1:]) ]
med_int = median(intervals)
period = next((p for p in PERIODICITIES if p[1] <= med_int <= p[2]), None)
if period is None: continue
# régularité : au moins 80 % des intervalles dans la fenêtre de la périodicité
ok = sum(1 for i in intervals if period[1] <= i <= period[2])
if ok / len(intervals) < 0.8: continue
amounts = [abs(t.amount) for t in items]
med_amt = median(amounts)
# stabilité du montant : écart absolu médian <= max(1 €, 10 % du montant médian)
mad = median([abs(a - med_amt) for a in amounts])
if mad > max(Decimal("1.00"), med_amt * Decimal("0.10")): continue
series.append(RecurringSeries(
merchant_key=key,
label_display=most_common(t.label_clean for t in items),
category=most_common_category(items), # catégorie majoritaire, sinon None
periodicity=period[0],
occurrences=len(dates),
average_amount=round(mean(amounts), 2),
expected_amount=med_amt, # prédiction = médiane (robuste)
last_date=dates[-1],
next_date_predicted=dates[-1] + timedelta(days=int(round(median(intervals)))),
is_active=(today - dates[-1]).days <= period[2] * 2, # inactif si 2 périodes manquées
))
series.sort(key=lambda s: (-s.is_active, s.next_date_predicted))
return series
```
Les revenus récurrents (salaire) sont détectés par le même algorithme exécuté sur `amount > 0` (paramètre `direction` de l'endpoint, §9.8).
---
## 8. Agrégats — définitions de calcul
### 8.1 Périmètre commun des statistiques
Toutes les stats de dépenses/revenus utilisent le prédicat commun (CTE ou vue SQL `fin_stats_base`) :
```sql
SELECT t.*, COALESCE(c_parent.id, c.id) AS root_category_id
FROM fin_transactions t
LEFT JOIN fin_categories c ON c.id = t.category_id
LEFT JOIN fin_categories c_parent ON c_parent.id = c.parent_id
WHERE t.user_id = :user_id
AND t.currency = 'EUR'
AND t.transfer_group_id IS NULL
AND (c.kind IS NULL OR c.kind <> 'transfer')
AND (:account_ids IS NULL OR t.account_id = ANY(:account_ids))
```
Mois d'une transaction : `to_char(booked_date, 'YYYY-MM')` — les dates sont civiles, aucune conversion de fuseau.
### 8.2 Rollup de l'arbre de catégories
L'arbre étant limité à 2 niveaux, le rollup se fait par le `LEFT JOIN` sur le parent ci-dessus (`root_category_id`). Le total d'une catégorie **racine** = ses transactions directes + celles de tous ses enfants. Une transaction sans catégorie va dans le pseudo-groupe `"uncategorized"` (affiché « Non catégorisé », couleur grise `#9ca3af`). Si l'arbre devait un jour dépasser 2 niveaux, remplacer le join par une CTE récursive — non nécessaire en v1.
### 8.3 Budget vs réalisé (mois M)
```
budget applicable = fin_budgets où start_month <= M et (end_month IS NULL ou end_month >= M)
actual(catégorie) = SOMME(ABS(amount)) des dépenses (amount < 0) du périmètre §8.1 pour le mois M
sur le sous-arbre de la catégorie budgétée (elle-même + enfants si racine)
progress_pct = round(actual / monthly_amount * 100, 1) (peut dépasser 100)
remaining = monthly_amount - actual (peut être négatif)
projected_eom = mois courant uniquement : actual / jours_écoulés * jours_du_mois (linéaire, arrondi 2 déc.)
```
---
## 9. API — spécification des endpoints
Préfixe commun : **`/api/v1/finance`**. Auth : JWT Bearer obligatoire partout (dépendance FastAPI commune du socle) ; le `user_id` provient exclusivement du token. Erreurs : enveloppe commune du socle `{ "detail": "message en français" }` avec codes 400/401/404/409/413/422. Validation : schémas Pydantic v2 dans `finance/schemas.py`.
**Pagination** (listes) : `?page=1&page_size=50` (max 200) → `{ "items": [...], "total": 1234, "page": 1, "page_size": 50 }`.
**Dates** : query params en ISO `YYYY-MM-DD` ; mois en `YYYY-MM`.
**Montants JSON** : nombres (sérialisation `Decimal` → 2 décimales via encoder Pydantic).
### 9.1 Comptes
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/accounts` | Liste (query `include_archived=false`). Chaque item inclut `balance` (calculé : `initial_balance + SUM(amount)`) et `transaction_count`. |
| `POST` | `/accounts` | Corps : `{name, kind, currency?, institution?, iban_masked?, initial_balance?}`. 409 si nom déjà pris. |
| `PATCH` | `/accounts/{id}` | Mise à jour partielle des mêmes champs + `is_archived`. |
| `DELETE` | `/accounts/{id}` | 409 si transactions existantes (message : « Archivez le compte à la place. »). |
### 9.2 Transactions
`GET /transactions` — filtres (tous optionnels, combinés en ET) :
| Param | Type | Sémantique |
|---|---|---|
| `date_from`, `date_to` | date | Sur `booked_date`, bornes incluses. |
| `account_id` | UUID, répétable | Multi-comptes. |
| `category_id` | UUID ou littéral `none`, répétable | `none` = non catégorisées. Une catégorie **racine** inclut ses enfants. |
| `q` | string | `ILIKE '%q%'` (via pg_trgm) sur `label_clean`, `label_raw`, `counterparty`, `notes`. |
| `direction` | `debit`\|`credit` | Signe du montant. |
| `amount_min`, `amount_max` | number | Sur `ABS(amount)`. |
| `is_transfer` | bool | `transfer_group_id IS (NOT) NULL`. |
| `import_run_id` | UUID | Transactions d'un import. |
| `sort` | string | `booked_date` (défaut, desc), `amount`, `label_clean` ; préfixe `-` pour desc. |
Item de réponse :
```json
{ "id": "…", "account_id": "…", "account_name": "BoursoBank", "booked_date": "2026-08-02",
"value_date": null, "amount": -42.90, "currency": "EUR",
"label_raw": "CARTE 01/08 CARREFOUR CITY PARIS", "label_clean": "Carrefour City Paris",
"counterparty": "Carrefour", "category_id": "…", "category_name": "Courses",
"category_color": "#10b981", "category_source": "rule", "notes": null,
"transfer_group_id": null, "external_id": null, "import_run_id": "…" }
```
Autres opérations :
| Méthode | Route | Description |
|---|---|---|
| `POST` | `/transactions` | Saisie manuelle : `{account_id, booked_date, amount, label_clean, category_id?, notes?, counterparty?}` ; `label_raw = label_clean`, `category_source = 'user'` si catégorie fournie ; dedup_hash calculé (§4.3, occurrence via requête count sur tuple identique). 409 si collision de hash. |
| `PATCH` | `/transactions/{id}` | Champs éditables : `label_clean`, `counterparty`, `category_id` (pose `category_source='user'` ; `null` remet `category_source=NULL`), `notes`, `booked_date`, `amount` (uniquement si saisie manuelle : `import_run_id IS NULL`, sinon 422). |
| `DELETE` | `/transactions/{id}` | Uniquement saisie manuelle (`import_run_id IS NULL`), sinon 422 (« Supprimez l'import complet ou ignorez la ligne. »). |
| `POST` | `/transactions/bulk-categorize` | `{ "transaction_ids": ["…"], "category_id": "…" }` (max 500 ids) → pose `category_source='user'` sur chaque ; réponse `{ "updated": 42 }`. `category_id: null` = décatégoriser. |
### 9.3 Catégories
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/categories` | Arbre complet : racines avec `children: [...]`, + `transaction_count` par catégorie. |
| `POST` | `/categories` | `{name, parent_id?, icon?, color?, kind?}` — kind hérité/forcé si parent ; profondeur max 2 (422). |
| `PATCH` | `/categories/{id}` | `name`, `icon`, `color`, `sort_order`, `parent_id` (re-parentage : refusé si la catégorie a des enfants et gagnerait un parent). 422 sur catégories système pour `name`/`kind`. |
| `DELETE` | `/categories/{id}` | 422 si `is_system`. Décatégorise les transactions (§2.2), supprime enfants, budgets liés, et nettoie `actions.set_category_id` des règles concernées (action mise à `null` ; règle désactivée si elle devient vide). |
### 9.4 Règles
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/rules` | Triées par `priority ASC`. Inclut `hit_count`, `last_applied_at`. |
| `POST` | `/rules` | `{name, priority?, enabled?, stop?, matchers, actions}` — validation des schémas §5.1/§5.2 (regex compilée à la validation ; 422 si invalide). |
| `PATCH` | `/rules/{id}` | Mise à jour partielle. |
| `DELETE` | `/rules/{id}` | Suppression (les transactions gardent leur catégorie, `applied_rule_id` devient un id orphelin, acceptable). |
| `POST` | `/rules/reorder` | `{ "ordered_ids": ["…"] }` → réécrit `priority = index * 10`. |
| `POST` | `/rules/apply` | §5.4. Corps : `{scope?, date_from?, date_to?, account_id?, rule_id?, dry_run?, force?}`. |
| `POST` | `/rules/preview` | Corps : `{matchers}` (règle non sauvegardée) → 50 premières transactions qui matcheraient + `total_matched`. Sert d'assistant de création depuis une transaction (« créer une règle depuis cette ligne »). |
### 9.5 Budgets
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/budgets` | Query `month=YYYY-MM` (défaut : mois courant) → budgets applicables ce mois, avec `actual`, `progress_pct`, `remaining`, `projected_eom` (§8.3). |
| `POST` | `/budgets` | `{category_id, monthly_amount, start_month, end_month?}` — 409 si chevauchement (§2.7), 422 si catégorie non-expense. |
| `PATCH` | `/budgets/{id}` | `monthly_amount`, `end_month` ; ou `{monthly_amount, effective_from: "YYYY-MM"}` → clôture + création (§2.7), réponse : les deux budgets. |
| `DELETE` | `/budgets/{id}` | Suppression simple. |
### 9.6 Imports & profils de source
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/source-profiles` | Presets intégrés + profils de l'utilisateur (`is_builtin` distingue). |
| `POST` | `/source-profiles` | Création d'un profil utilisateur `{name, kind, config}` (config validé par le schéma du kind). |
| `POST` | `/source-profiles/{id}/clone` | Clone un preset (ou un profil) vers un profil utilisateur éditable. |
| `PATCH` / `DELETE` | `/source-profiles/{id}` | 403 sur les builtins. |
| `POST` | `/imports/preview` | Multipart : `file` + `source_profile_id` + `account_id`. Parse **sans écrire** : `{ "rows_preview": [20 premières NormalizedRow sérialisées], "rows_total": 143, "rows_error": 1, "would_skip_duplicates": 21, "date_min": "…", "date_max": "…", "errors": [...] }`. L'UI affiche ce retour avant confirmation. |
| `POST` | `/imports` | Multipart identique → exécute §4.4, réponse : l'`ImportRun` complet avec `stats`. |
| `GET` | `/imports` | Historique paginé des runs. |
| `GET` | `/imports/{id}` | Détail d'un run. |
| `DELETE` | `/imports/{id}` | Rollback (§2.4), query `force=true` pour passer outre les modifications manuelles. |
### 9.7 Virements
| Méthode | Route | Description |
|---|---|---|
| `POST` | `/transfers/detect` | Corps optionnel `{date_from?, date_to?}``{ "pairs_created": 3 }`. |
| `POST` | `/transfers/link` | `{transaction_id_a, transaction_id_b}` (§6). |
| `DELETE` | `/transfers/{transfer_group_id}` | Déliaison (§6). |
### 9.8 Statistiques (chart-ready ECharts)
Tous ces endpoints acceptent `account_id` (répétable) pour restreindre le périmètre ; défaut = tous les comptes non archivés. Les montants de dépenses sont retournés **en valeur absolue** (positive) — le signe est porté par la sémantique du champ.
#### `GET /stats/monthly-by-category?months=12&level=root&direction=debit`
Barres empilées par mois. `level` : `root` (rollup §8.2, défaut) | `child` (catégories feuilles). Réponse :
```json
{
"months": ["2025-09", "2025-10", "…", "2026-08"],
"series": [
{ "category_id": "…", "name": "Alimentation", "color": "#10b981",
"data": [412.50, 388.10, 0, 401.00, "…"] },
{ "category_id": null, "name": "Non catégorisé", "color": "#9ca3af", "data": ["…"] }
],
"totals": [1830.20, 1795.00, "…"]
}
```
`data[i]` correspond à `months[i]` ; mois sans dépense = `0`. Mapping ECharts direct : `xAxis.data = months`, une `series` bar `stack:'total'` par entrée.
#### `GET /stats/cashflow?months=12`
```json
{ "months": ["2025-09", "…"],
"income": [2843.00, "…"], "expenses": [1830.20, "…"],
"net": [1012.80, "…"], "cumulative_net": [1012.80, 2130.60, "…"] }
```
#### `GET /stats/top-merchants?months=3&limit=15&direction=debit`
Groupé par `COALESCE(counterparty, merchant_key(label_clean))` :
```json
{ "period": { "from": "2026-06-01", "to": "2026-08-31" },
"items": [ { "merchant": "Carrefour", "total": 512.40, "count": 14,
"average": 36.60, "category_name": "Courses", "category_color": "#10b981" } ] }
```
#### `GET /stats/recurring?direction=debit&include_inactive=false`
Sérialisation directe de §7.2 :
```json
{ "items": [ { "merchant_key": "NETFLIX", "label_display": "Netflix",
"category_id": "…", "category_name": "Abonnements & streaming",
"periodicity": "monthly", "occurrences": 14, "average_amount": 13.49,
"expected_amount": 13.49, "last_date": "2026-07-28",
"next_date_predicted": "2026-08-27", "is_active": true } ],
"monthly_total_estimate": 187.40 }
```
`monthly_total_estimate` = somme des `expected_amount` actifs normalisés au mois (weekly ×4.33, quarterly ÷3, yearly ÷12).
#### `GET /stats/budget-progress?month=2026-08`
```json
{ "month": "2026-08", "items": [
{ "budget_id": "…", "category_id": "…", "category_name": "Alimentation",
"category_color": "#10b981", "budget": 450.00, "actual": 312.40,
"remaining": 137.60, "progress_pct": 69.4, "projected_eom": 468.60,
"status": "warning" } ],
"totals": { "budget": 1650.00, "actual": 1204.10, "progress_pct": 73.0 } }
```
`status` : `ok` (< 80 %), `warning` (80100 % ou `projected_eom > budget`), `over` (> 100 %). `projected_eom` = `null` pour les mois passés.
#### `GET /stats/sankey?month=2026-08` (ou `?months=3` : agrégat de la période)
Trois étages : catégories de revenus → nœud central `"Revenus"` → catégories racines de dépenses → catégories enfants (uniquement celles avec dépense > 0). Solde : si revenus > dépenses, lien `Revenus → Épargne du mois` avec l'excédent ; si déficit, nœud `Découvert / réserves → Revenus` avec le manque. Format **directement consommable par `series-sankey` ECharts** (les nœuds sont référencés par `name`, garantis uniques — préfixer un enfant homonyme par « Parent · Enfant ») :
```json
{
"period": { "from": "2026-08-01", "to": "2026-08-31" },
"nodes": [
{ "name": "Salaire", "color": "#3b82f6" }, { "name": "Revenus", "color": "#64748b" },
{ "name": "Alimentation", "color": "#10b981" }, { "name": "Courses", "color": "#10b981" },
{ "name": "Épargne du mois", "color": "#22c55e" }
],
"links": [
{ "source": "Salaire", "target": "Revenus", "value": 2843.00 },
{ "source": "Revenus", "target": "Alimentation", "value": 412.50 },
{ "source": "Alimentation", "target": "Courses", "value": 355.20 },
{ "source": "Revenus", "target": "Épargne du mois", "value": 1012.80 }
]
}
```
Les transactions non catégorisées apparaissent comme nœud « Non catégorisé » côté dépenses (et « Autres revenus » côté revenus si crédits non catégorisés). Les virements internes sont exclus (§8.1).
---
## 10. Points d'implémentation et cas limites (checklist)
1. **Encodage cp1252** : le mode `"auto"` (§4.1) ne peut pas échouer ; ne jamais utiliser `chardet` (dépendance inutile).
2. **Virgule décimale** : toujours passer par le nettoyage §3.1 avant `Decimal(...)` ; tester `"1 234,56"`, `"1.234,56"` (thousands `.`), `"-12,5"`, `"12,50"` (U+2212), `"12,50 €"`.
3. **Deux transactions identiques le même jour** : couvertes par `occurrence` (§4.3) — test unitaire obligatoire (même fichier ré-importé = 0 insertion ; fichier avec 2 lignes identiques = 2 insertions).
4. **OFX FITID non fiable chez certaines banques** (FITID régénérés) : la dédup par hash (§4.3) reste le filet de sécurité — l'`external_id` en doublon est ignoré au profit du test de hash si l'`external_id` n'existe pas encore mais que le hash existe.
5. **Ordre du pipeline** : règles appliquées **avant** insertion (une passe), détection de virements **après** insertion (besoin des deux jambes en base).
6. **`category_source = 'user'` est sacré** : aucun traitement automatique (règles, virements) n'écrase une décision manuelle, sauf `force`.
7. **Montants dans les stats** : dépenses en valeur absolue, revenus positifs ; ne jamais additionner des signes mélangés sans filtre `direction`.
8. **Tests de non-régression importeurs** : un fichier d'exemple anonymisé par preset dans `api/tests/fixtures/finance/` (BoursoBank, CA, LBP, SG, Fortuneo, OFX 1.x, OFX 2.x, PayPal FR, PayPal EN) avec snapshot des `NormalizedRow` attendues.
9. **Performance** : volumes attendus < 100 k transactions ; les index définis en §2.5 suffisent. Les stats font des agrégats SQL (jamais de boucle Python sur toutes les transactions), sauf la détection de récurrences (§7) qui charge 18 mois de dépenses (~5 k lignes max, acceptable).
10. **UI française** : libellés d'erreurs API en français (ils remontent tels quels dans l'UI) ; formats d'affichage : dates `dd/MM/yyyy`, montants `1 234,56 €` (espace insécable), gérés côté front par `Intl.NumberFormat('fr-FR', {style:'currency', currency:'EUR'})`.
File diff suppressed because it is too large Load Diff
+872
View File
@@ -0,0 +1,872 @@
# LifeTrack — Spécification UX complète (page par page)
> **Document de référence pour l'implémentation.** Prose en français, identifiants de code en anglais.
> Stack imposée : React 18 + TypeScript + Vite + TailwindCSS + Apache ECharts. UI 100 % en français, thème sombre par défaut.
> Ce document est exhaustif : les agents d'implémentation ne feront **aucune** recherche complémentaire.
---
## Table des matières
1. [Principes globaux et layout](#1-principes-globaux-et-layout)
2. [Formatage français (nombres, dates, unités)](#2-formatage-français)
3. [Sémantique des couleurs](#3-sémantique-des-couleurs)
4. [Design system (tokens Tailwind, composants, palette charts)](#4-design-system)
5. [Composants transverses](#5-composants-transverses)
6. [Configuration ECharts commune](#6-configuration-echarts-commune)
7. [Page — Tableau de bord](#7-page--tableau-de-bord)
8. [Page — Poids & Objectif](#8-page--poids--objectif)
9. [Page — Nutrition](#9-page--nutrition)
10. [Page — Activité & Sport](#10-page--activité--sport)
11. [Page — Balance énergétique](#11-page--balance-énergétique)
12. [Page — Vape](#12-page--vape)
13. [Page — Finances](#13-page--finances)
14. [Page — Imports](#14-page--imports)
15. [Page — Réglages](#15-page--réglages)
16. [États vides (empty states) — récapitulatif](#16-états-vides)
17. [Accessibilité](#17-accessibilité)
---
## 1. Principes globaux et layout
### 1.1 Structure générale
```
┌────────────┬──────────────────────────────────────────────┐
│ │ Topbar : titre de page + PeriodSelector + │
│ Sidebar │ actions rapides + menu utilisateur │
│ (nav) ├──────────────────────────────────────────────┤
│ │ Contenu : grille de KpiCard, ChartCard, │
│ │ tables, sections │
└────────────┴──────────────────────────────────────────────┘
```
- **Sidebar** (desktop ≥ 1024 px) : largeur `260px`, repliable en mode icônes `72px` (bouton chevron en bas de la sidebar, état persisté dans `localStorage`). Fond `bg-base` (`#0D0D0D`), séparée du contenu par une bordure hairline.
- **Entrées de navigation** (ordre fixe, icônes Lucide entre parenthèses) :
1. **Tableau de bord** (`layout-dashboard`) — route `/`
2. **Poids & Objectif** (`scale`) — route `/poids`
3. **Nutrition** (`utensils`) — route `/nutrition`
4. **Activité & Sport** (`footprints`) — route `/activite`
5. **Balance énergétique** (`flame`) — route `/balance`
6. **Vape** (`cloud`) — route `/vape`
7. **Finances** (`wallet`) — route `/finances`
8. **Imports** (`upload`) — route `/imports`
9. **Réglages** (`settings`) — route `/reglages`
- Item actif : fond `bg-surface-2`, barre verticale `3px` couleur `accent` (#3987E5) à gauche, texte `text-primary`. Items inactifs : `text-secondary`, hover `bg-surface-2/50`.
- En bas de sidebar : logo + version + bouton repli.
- **Topbar** : hauteur `56px`, contient (gauche → droite) : titre de la page (h1, 18 px semibold), `PeriodSelector` (voir §5.1), bouton **« + Ajouter »** (menu déroulant d'actions rapides, voir §5.6), avatar/menu utilisateur (Profil, Réglages, Déconnexion).
### 1.2 Responsive
Breakpoints Tailwind standard : `sm` 640, `md` 768, `lg` 1024, `xl` 1280, `2xl` 1536.
| Élément | Mobile (< 768) | Tablette (7681023) | Desktop (≥ 1024) |
|---|---|---|---|
| Navigation | Barre inférieure fixe 5 items : Tableau de bord, Poids, Nutrition, Vape, **« Plus »** (bottom-sheet listant les autres pages) | Sidebar repliée (icônes) | Sidebar complète |
| PeriodSelector | Menu déroulant compact (icône calendrier + libellé court) | Segmented control | Segmented control |
| Grille KPI | 2 colonnes | 3 colonnes | 4 à 6 colonnes |
| ChartCard | Pleine largeur, hauteur min `240px` | 12 colonnes | Grille 2 colonnes (graphique principal en pleine largeur) |
| Tables | Défilement horizontal **dans la carte** (`overflow-x-auto`), colonnes clés épinglées à gauche (Date, Libellé) ; jamais de scroll horizontal de page | idem | Table complète |
| Ajout rapide | FAB `+` en bas à droite (au-dessus de la nav), ouvre le menu d'actions rapides | Bouton topbar | Bouton topbar |
- Toutes les zones tactiles ≥ `44px`. Les graphiques restent lisibles à 360 px de large (labels d'axe X inclinés ou échantillonnés via `axisLabel.interval: 'auto'`).
- Les modales deviennent des **bottom-sheets** plein écran sur mobile.
### 1.3 Thème
- **Sombre par défaut.** Un thème clair existe (Réglages → Application) mais le sombre est la référence de conception. Tout hex de ce document est donné pour le fond sombre.
- `color-scheme: dark` sur `:root[data-theme="dark"]` ; bascule via attribut `data-theme` (`dark` | `light` | `auto`).
---
## 2. Formatage français
Toutes les valeurs affichées passent par des helpers centralisés (`src/lib/format.ts`) basés sur `Intl` avec locale `fr-FR`. Le séparateur de milliers est l'**espace fine insécable** (U+202F, produite nativement par `Intl.NumberFormat('fr-FR')`), le séparateur décimal est la **virgule**.
| Type | Helper | Exemple |
|---|---|---|
| Monnaie | `formatCurrency(v)``Intl.NumberFormat('fr-FR', {style:'currency', currency:'EUR'})` | `1 234,56 €` · `45,90 €` |
| Poids | `formatWeight(v)` — 1 décimale | `82,4 kg` |
| Calories | `formatKcal(v)` — entier groupé | `1 850 kcal` |
| Millilitres | `formatMl(v)` — 1 décimale | `4,2 ml` |
| Nicotine | `formatMg(v)` — 1 décimale | `12,6 mg` |
| Distance | `formatKm(v)` — 2 décimales | `5,25 km` |
| Pas | `formatSteps(v)` — entier groupé | `9 542` |
| Pourcentage | `formatPercent(v)` — espace avant % | `82 %` |
| Grammes (macros) | `formatG(v)` — entier | `132 g` |
| Durée | `formatDuration(min)` | `1 h 05` · `45 min` |
| Date | `formatDate(d)``dd/MM/yyyy` | `13/08/2026` |
| Date + heure | `formatDateTime(d)``dd/MM/yyyy HH:mm` | `13/08/2026 19:30` |
| Date courte (axes) | `formatDateShort(d)``dd/MM` | `13/08` |
| Mois (axes) | `formatMonth(d)` | `août 2026` (axe : `août 26`) |
| Semaine (axes) | `formatWeek(d)` | `Sem. 33` (tooltip : `du 10/08 au 16/08`) |
| Nombre signé | `formatSigned(v)` — signe explicite, « » U+2212 | `+0,3 kg` · `450 kcal` |
Règles :
- **Saisie** : les champs numériques acceptent la virgule **et** le point comme séparateur décimal (normalisation à la volée).
- **Stockage** : UTC en base ; affichage converti en `Europe/Paris` côté client (le backend renvoie de l'ISO 8601 UTC).
- « Aujourd'hui » et « Hier » remplacent la date dans les listes quand pertinent (journal nutrition, dernières transactions).
- Alignement : montants et nombres **alignés à droite** dans les tables, avec `font-variant-numeric: tabular-nums`.
---
## 3. Sémantique des couleurs
Deux familles distinctes : la **palette de séries** (identité, §4.4) et les **couleurs sémantiques** (polarité/état). Ne jamais utiliser une couleur sémantique comme couleur de série ordinaire.
| Signification | Couleur (fond sombre) | Usage |
|---|---|---|
| **Positif / favorable** — déficit calorique, économies vape, cashflow positif, budget respecté, perte de poids (si l'objectif est de perdre) | `#0CA30C` (`semantic-positive`) | Barres de balance nette en déficit, aire « Économies cumulées », deltas KPI favorables, cashflow > 0 |
| **Négatif / défavorable** — surplus calorique, dépassement de budget, cashflow négatif, reprise de poids | `#D03B3B` (`semantic-negative`) | Barres de surplus, budget > 100 %, deltas KPI défavorables |
| **Avertissement** — budget entre 80 et 100 %, résistance en fin de vie, import partiel | `#FAB219` (`semantic-warning`) | Badges, jauges, statuts |
| **Sérieux** (entre warning et negative) | `#EC835A` (`semantic-serious`) | Réservé aux statuts d'import et alertes |
| **Neutre / information** | `#3987E5` (slot 1 de la palette) | Liens, éléments actifs, info |
Règles impératives :
- **Direction consciente de l'objectif** : pour le poids, le vert signifie « va dans le sens de l'objectif ». Si `goal.direction = lose`, une variation négative est verte ; si `gain`, c'est l'inverse. Centraliser dans un helper `deltaTone(value, goalDirection)`.
- Une couleur sémantique n'est **jamais seule** porteuse de sens : elle est toujours accompagnée d'un signe (`+`/``), d'une icône (`trending-down`, `alert-triangle`…) ou d'un libellé.
- Montants dans les tables Finances : dépenses en `text-primary` avec signe ``, revenus en `semantic-positive` avec signe `+` (le signe porte l'info, la couleur renforce).
---
## 4. Design system
### 4.1 Tokens Tailwind (extrait de `tailwind.config.ts`)
```ts
// tailwind.config.ts — theme.extend
colors: {
base: '#0D0D0D', // fond de page
surface: '#1A1A19', // fond des cartes / graphiques
'surface-2': '#242423', // hover, éléments imbriqués, inputs
border: 'rgba(255,255,255,0.10)', // bordure hairline
ink: {
DEFAULT: '#FFFFFF', // text-primary
secondary:'#C3C2B7',
muted: '#898781', // labels d'axes, placeholders
},
grid: '#2C2C2A', // lignes de grille des graphiques
axis: '#383835', // ligne de base / axe
accent: '#3987E5',
semantic: {
positive: '#0CA30C',
negative: '#D03B3B',
warning: '#FAB219',
serious: '#EC835A',
},
chart: {
1: '#3987E5', 2: '#D95926', 3: '#199E70', 4: '#C98500',
5: '#D55181', 6: '#008300', 7: '#9085E9', 8: '#E66767',
},
},
borderRadius: { card: '12px' },
fontFamily: { sans: ['system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'] },
```
Thème clair (non prioritaire, mêmes rôles) : `base #F9F9F7`, `surface #FCFCFB`, `ink #0B0B0B`, `ink-secondary #52514E`, `border rgba(11,11,11,0.10)`, `grid #E1E0D9`, palette charts clair : `#2A78D6, #EB6834, #1BAF7A, #EDA100, #E87BA4, #008300, #4A3AA7, #E34948`, `semantic-positive` texte : `#006300`.
### 4.2 Composant `Card`
Base de **toutes** les cartes (KPI, graphiques, tables, sections de réglages) :
- Fond `bg-surface`, `rounded-card` (12 px), bordure `1px solid border`, padding `16px` (mobile) / `20px` (desktop). Pas d'ombre portée (thème sombre) — la séparation vient du contraste fond/carte et de la bordure.
- En-tête optionnel : titre 14 px semibold `text-ink`, sous-titre 12 px `text-ink-muted`, zone d'actions à droite.
### 4.3 `KpiCard`
```
┌──────────────────────────────┐
│ LIBELLÉ (12px, muted, caps) │
│ 82,4 kg (28px, semibold) │
│ ▼ 0,4 kg sur 7 j (12px) │ ← delta coloré + icône
│ [sparkline optionnelle 40px] │
└──────────────────────────────┘
```
- Valeur principale : 28 px desktop / 24 px mobile, `text-ink`.
- Delta : icône `trending-up`/`trending-down` + valeur signée, colorée via `deltaTone`.
- Variante avec **jauge de progression** (barre 6 px arrondie) pour les KPI « X / budget ».
- Toute la carte est cliquable quand elle renvoie vers une page de détail (curseur pointer + hover `bg-surface-2/40`).
### 4.4 Palette de graphiques (fond sombre — **validée CVD, ne pas réordonner**)
L'ordre des slots est un mécanisme de sécurité daltonisme (validé par script : pire paire adjacente CVD ΔE 8,4 ; vision normale 19,3 ; tous les slots ≥ 3:1 de contraste sur `#1A1A19`). **Attribution fixe par entité — jamais recyclée, jamais réassignée quand une série est filtrée.**
| Slot | Hex | Attribution LifeTrack (identité fixe) |
|---|---|---|
| `chart-1` bleu | `#3987E5` | Poids (tendance), dépense énergétique (kcal out), distance, solde total |
| `chart-2` orange | `#D95926` | Apports caloriques (kcal in), kcal actives |
| `chart-3` vert d'eau | `#199E70` | Protéines, pas quotidiens |
| `chart-4` jaune | `#C98500` | Glucides, coût vape |
| `chart-5` magenta | `#D55181` | Lipides, nicotine |
| `chart-6` vert | `#008300` | (réservé — éviter près de `semantic-positive`) |
| `chart-7` violet | `#9085E9` | Vape (ml), poids théorique |
| `chart-8` rouge | `#E66767` | Dernier recours (jamais pour un sens « négatif ») |
- Variantes d'une même entité (ex. projection du poids) : **teintes séquentielles du même bleu**`#86B6EF` (clair), `#3987E5` (base), `#1C5CAB` (foncé) — jamais un nouveau slot.
- Catégories de dépenses (Finances) : slots 1→7 dans l'ordre, au-delà **regroupement « Autres »** en `ink-muted` (`#898781`). Pour les donuts et le sankey (formes où toutes les paires se comparent), limiter à 7 catégories + « Autres ».
- Chaque catégorie financière reçoit un slot **à sa création** (stocké en base) → couleur stable dans le temps.
- ≥ 2 séries ⇒ **légende toujours affichée** ; 1 série ⇒ pas de légende (le titre nomme la série).
- Jamais de valeur numérique sur chaque point : labels directs **sélectifs** (dernier point, max, min) uniquement.
- **Jamais de double axe Y.** Deux mesures d'échelles différentes = deux graphiques empilés partageant le même axe X et le même zoom (`echarts.connect`).
### 4.5 Boutons et champs
- Bouton primaire : fond `accent`, texte blanc, `rounded-lg`, hauteur 40 px. Secondaire : fond `surface-2`, bordure hairline. Destructif : fond `semantic-negative`. Ghost : texte `accent`.
- Inputs : fond `surface-2`, bordure hairline, focus ring `accent`, hauteur 40 px, labels au-dessus (12 px `text-secondary`), erreurs en 12 px `semantic-negative` sous le champ.
- Selects natifs stylés + combobox avec recherche pour listes longues (catégories, aliments).
---
## 5. Composants transverses
### 5.1 `PeriodSelector` (sélecteur de période)
- Segmented control : **`7 j` · `30 j` · `90 j` · `1 an` · `Tout` · `Personnalisé`**. Option active : fond `surface-2`, texte `text-ink` ; inactives `text-secondary`.
- `Personnalisé` ouvre un popover avec deux champs date (`dd/MM/yyyy`, datepicker fr, lundi premier jour de semaine) + raccourcis « Ce mois-ci », « Le mois dernier », « Cette année ». Libellé affiché ensuite : `01/06/2026 13/08/2026`.
- Par défaut : **30 j** partout, sauf Finances (**Ce mois-ci**) et Imports (pas de période).
- La période est **globale à la page**, persistée par page (`localStorage`, clé `period:<route>`) et reflétée dans l'URL (`?periode=30j` ou `?du=2026-06-01&au=2026-08-13`).
- Chaque `ChartCard` peut surcharger localement sa période (menu ⋯), la surcharge est signalée par un badge sur la carte.
### 5.2 `ChartCard`
`Card` + en-tête standard : titre du graphique, sous-titre optionnel (période effective), actions à droite : menu `⋯` avec **« Voir les données »** (bascule le graphique en table triable des mêmes valeurs — obligatoire, c'est la vue accessible), **« Exporter PNG »**, **« Exporter CSV »**, **« Plein écran »**. Hauteur par défaut du canvas : 280 px (mini-graphes du tableau de bord : 120 px ; graphiques principaux : 360 px).
### 5.3 `DataTable`
- En-têtes triables (flèche), lignes hauteur 44 px, zébrage désactivé (bordures hairline entre lignes), hover `surface-2/40`.
- Pagination 20 lignes (50/100 au choix), pied « 120 sur 254 ».
- Barre de filtres au-dessus, **sur une seule ligne** (wrap sur mobile) ; champ recherche à gauche, filtres à droite.
- Ligne vide → composant `EmptyState` (voir §16).
### 5.4 `EmptyState`
Illustration légère (icône 48 px `ink-muted`), titre 16 px, texte d'aide 14 px `text-secondary`, **1 à 2 boutons d'action** (primaire = action de saisie ou d'import). Textes exacts par page en §16.
### 5.5 Modales
Overlay `rgba(0,0,0,0.6)`, carte centrée `max-w-md` (formulaires rapides) ou `max-w-2xl` (règles, mapping d'import). Titre + croix de fermeture. Boutons en pied : « Annuler » (ghost) + action primaire. `Esc` ferme, `Entrée` valide les formulaires à un champ principal. Sur mobile : bottom-sheet plein écran.
### 5.6 Menu « + Ajouter » (actions rapides globales)
Disponible partout (topbar / FAB mobile). Items :
1. **« Pesée »** → modale Quick-add poids (§8.5)
2. **« Aliment »** → modale Quick-add aliment (§9.5)
3. **« Recharge vape »** → modale Quick-add recharge (§12.5)
4. **« Résistance changée »** → action un clic + toast (§12.6)
5. **« Séance de sport »** → modale séance (§10.5)
6. **« Transaction »** → modale transaction manuelle (§13.6)
7. **« Importer un fichier »** → route `/imports`
Raccourcis clavier : `a` ouvre le menu ; `p` pesée ; `n` aliment ; `v` recharge.
### 5.7 Toasts
En bas à droite (desktop) / haut (mobile). Succès : « Pesée enregistrée ✓ » avec lien « Annuler » (undo 5 s). Erreur : `semantic-negative` + détail.
---
## 6. Configuration ECharts commune
Fichier `src/lib/echarts.ts` : enregistrement des composants nécessaires (tree-shaking), locale `FR` d'ECharts, et un thème `lifetrack-dark` :
```ts
// Theme ECharts "lifetrack-dark" (extrait)
{
backgroundColor: 'transparent',
color: ['#3987E5','#D95926','#199E70','#C98500','#D55181','#008300','#9085E9','#E66767'],
textStyle: { color: '#C3C2B7', fontFamily: 'system-ui, "Segoe UI", sans-serif' },
axisLine: { lineStyle: { color: '#383835' } },
splitLine: { lineStyle: { color: '#2C2C2A' } },
axisLabel: { color: '#898781', fontSize: 11 },
legend: { textStyle: { color: '#C3C2B7' }, icon: 'circle', itemWidth: 8, itemHeight: 8, top: 0 },
tooltip: {
backgroundColor: '#1A1A19', borderColor: 'rgba(255,255,255,0.10)',
textStyle: { color: '#FFFFFF' }, padding: [8, 12],
},
}
```
Règles communes à **tous** les graphiques :
- `grid: { left: 8, right: 16, top: 36, bottom: 8, containLabel: true }` (bottom 44 si `dataZoom` slider).
- **Tooltip** : `trigger: 'axis'` avec `axisPointer: { type: 'line' }` pour les séries temporelles ; `trigger: 'item'` pour donut/sankey/barres horizontales. Formatter systématiquement en français via les helpers de §2 (date complète en tête, puis `● Série valeur` par ligne, valeurs alignées à droite).
- **Zoom** : tous les graphiques temporels reçoivent `dataZoom: [{ type: 'inside' }]` (molette + pincement) ; les graphiques « principaux » de page (poids, transactions par mois, ml vape) ajoutent `{ type: 'slider', height: 20 }`.
- Lignes : `width: 2`, `symbol: 'circle'`, `symbolSize: 6`, `showSymbol: false` (symboles visibles au hover seulement) ; `smooth: 0.2` maximum, jamais de lissage exagéré.
- Barres : `barMaxWidth: 28`, coins arrondis côté extrémité de donnée uniquement (`borderRadius: [4,4,0,0]` vers le haut, inversé vers le bas), **espace de 2 px** entre segments empilés (`itemStyle.borderColor: '#1A1A19', borderWidth: 1`).
- Aires : dégradé vertical de la couleur de série à 25 % → 0 % d'opacité.
- Axe Y : commence à 0 pour les barres ; pour le poids (ligne), `min`/`max` auto avec marge (`scale: true`).
- `markLine` (budgets, objectifs) : pointillés `[4,4]`, couleur `#C3C2B7`, label au bout à droite (ex. « Budget 1 800 »), `symbol: 'none'`.
- Pas d'animation à la mise à jour de période > 300 ms (`animationDuration: 300`).
- Chaque graphique expose `aria-label` descriptif et l'alternative « Voir les données » (§5.2).
---
## 7. Page — Tableau de bord
**Route** `/` · **Titre** « Tableau de bord » · **Période par défaut** : 30 j (s'applique aux mini-graphes ; les KPI « aujourd'hui » et « ce mois-ci » l'ignorent).
**Objectif UX** : une vue croisée de tous les modules en un écran, chaque bloc cliquable vers sa page de détail. Composé d'une rangée de 6 KPI, d'une rangée d'actions rapides, puis d'une grille de 6 **cartes-modules** avec mini-graphe.
### 7.1 KPI (rangée 1 — 6 cartes, 2×3 sur mobile)
| # | Libellé | Valeur | Sous-texte | Couleur delta |
|---|---|---|---|---|
| 1 | **Poids actuel** | `82,4 kg` (dernière pesée) | `▼ 0,4 kg sur 7 j` (delta de la tendance EMA) | `deltaTone` selon objectif |
| 2 | **Calories aujourd'hui** | `1 450 / 1 800 kcal` | jauge + `Reste 350 kcal` (ou `Dépassement de 120 kcal` en rouge) | vert ≤ budget, rouge > |
| 3 | **Déficit cumulé (30 j)** | `12 450 kcal` | `≈ 1,6 kg théoriques` | vert si déficit |
| 4 | **Vape aujourd'hui** | `3,8 ml` | `≈ 11,4 mg de nicotine` | neutre |
| 5 | **Économies vape** | `1 245,80 €` | `depuis le 15/03/2025` | toujours vert |
| 6 | **Dépenses du mois** | `1 234,56 € / 1 500,00 €` | jauge + `82 % du budget global` | vert < 80 %, jaune 80100 %, rouge > 100 % |
Si un module n'a pas de données, sa KpiCard affiche `—` + lien « Configurer ».
### 7.2 Actions rapides (rangée 2)
Boutons pleine largeur sur une ligne : **« + Pesée » · « + Aliment » · « + Recharge vape » · « Résistance changée » · « + Séance » · « Importer »** — mêmes actions que §5.6, en accès direct.
### 7.3 Cartes-modules (grille 2 colonnes desktop, 1 colonne mobile)
Chaque carte : titre + valeur clé + mini-graphe ECharts (120 px, sans axe Y visible, axe X en dates courtes, tooltip actif, pas de zoom) + lien « Voir le détail → ».
1. **« Poids »** — mini-line : tendance EMA (bleu `chart-1`, 2 px) + `markLine` horizontale objectif (pointillés muted, label « Objectif 78,0 kg »). Période sélectionnée.
2. **« Nutrition »** — mini-bar 7 derniers jours : kcal/jour, chaque barre colorée `semantic-positive` si ≤ budget du jour, `semantic-negative` sinon + `markLine` budget.
3. **« Balance énergétique »** — mini-bar : balance nette/jour (in out), barres vertes si < 0 (déficit), rouges si > 0, ligne zéro visible (`axisLine` sur y=0).
4. **« Vape »** — mini-line ml/jour (violet `chart-7`) + moyenne mobile 7 j en trait plein, valeurs brutes en points 40 % d'opacité.
5. **« Économies »** — mini-area cumulée verte (`#0CA30C`, dégradé) ; label direct sur le dernier point : `1 245,80 €`.
6. **« Finances »** — mini-bar 6 derniers mois : dépenses/mois (barres `chart-1`) + `markLine` budget mensuel global ; le mois courant est hachuré (mois incomplet, `decal` ECharts).
### 7.4 États particuliers
- Première visite (aucune donnée nulle part) : le tableau de bord est remplacé par un **écran d'accueil** : « Bienvenue sur LifeTrack 👋 » + 3 cartes d'onboarding : « Configurez votre profil » (→ `/reglages`), « Définissez votre objectif de poids » (→ `/reglages?tab=objectif`), « Importez vos premières données » (→ `/imports`).
---
## 8. Page — Poids & Objectif
**Route** `/poids` · **Titre** « Poids & Objectif » · **Période par défaut** : 90 j.
Définitions de calcul (implémentation) :
- `trendWeight` : EMA des pesées, `alpha = 0.1` (≈ tendance sur ~20 jours), calculée sur jours calendaires (interpolation : l'EMA n'avance que sur les jours avec pesée).
- `weeklyRate` : pente de la tendance sur 7 jours glissants, en kg/semaine.
- `projection` : régression linéaire de la tendance sur les 21 derniers jours, extrapolée jusqu'à `goal.targetWeight` (bornée à +365 j).
- `bmi = poids / taille²`.
### 8.1 KPI (6 cartes)
| Libellé | Valeur | Sous-texte |
|---|---|---|
| **Poids actuel** | `82,4 kg` | `Pesée du 13/08/2026` |
| **Tendance (EMA)** | `82,7 kg` | `▼ 0,4 kg sur 7 j` |
| **Rythme hebdo** | `0,45 kg/sem` | `Objectif : 0,50 kg/sem` (vert si |rythme| ≥ objectif dans le bon sens) |
| **IMC** | `26,3` | `Surpoids` (catégories : `< 18,5 Maigreur` · `18,525 Corpulence normale` · `2530 Surpoids` · `≥ 30 Obésité`) |
| **Objectif** | `78,0 kg` | `Reste 4,4 kg · 61 %` + jauge (départ → objectif) |
| **Atteinte estimée** | `24/10/2026` | `dans 10 semaines` (ou `— · rythme insuffisant` si la pente ne converge pas) |
### 8.2 Graphiques
**G1 — « Évolution du poids »** (principal, pleine largeur, 360 px)
- Type : `line` + `scatter` combinés, axe X `time` (dates), axe Y kg (`scale: true`, marge ±1 kg).
- Séries :
1. `Pesées` — scatter, symboles 6 px, `#3987E5` à 45 % d'opacité ;
2. `Tendance` — line 2 px `#3987E5`, sans symboles ;
3. `Projection` — line pointillée `[6,4]` `#86B6EF`, ne démarre qu'au dernier point de tendance, s'étend dans le futur ;
4. `Objectif``markLine` horizontale à `targetWeight`, pointillés, label « Objectif 78,0 kg » ;
5. `markPoint` discret à l'intersection projection/objectif avec label date estimée.
- Interactions : tooltip axe (Date · Pesée · Tendance), `dataZoom` inside + slider, légende (3 séries), clic-légende pour masquer les pesées brutes.
**G2 — « Rythme hebdomadaire »** (demi-largeur)
- Type : `bar`, une barre par semaine ISO, axe X semaines (`Sem. 31`…), axe Y kg/sem.
- Couleur par pièce (`visualMap.pieces`) : variation dans le sens de l'objectif → `semantic-positive`, sens inverse → `semantic-negative` (direction-aware §3).
- `markLine` au rythme cible (ex. `0,50`). Tooltip item : `Sem. 32 · du 03/08 au 09/08 — 0,45 kg`.
**G3 — « IMC »** (demi-largeur)
- Type : `line` (IMC de la tendance), axe Y IMC `min 16, max 35`.
- `markArea` de fond aux 4 bandes (opacité 6 % : bleu maigreur, vert normal, jaune surpoids, rouge obésité) + labels de bande à droite en 10 px muted. Tooltip : `13/08/2026 — IMC 26,3 (Surpoids)`.
**G4 — « Mensurations »** (pleine largeur, affiché seulement si ≥ 1 mesure existe)
- Type : `line` multi-séries, axe Y cm. Séries (ordre de palette) : `Tour de taille` (1), `Hanches` (2), `Poitrine` (3), `Bras` (4), `Cuisse` (5). Légende obligatoire + labels directs en bout de ligne. Tooltip axe. `connect` du zoom avec G1.
### 8.3 Table « Historique des pesées »
Colonnes : `Date` · `Poids` · `Tendance` · `Variation` (vs pesée précédente, signée et colorée) · `Note` · actions (✎ modifier, 🗑 supprimer avec confirmation). Tri par date desc. Bouton d'en-tête « + Ajouter une pesée ».
### 8.4 Carte « Objectif » (résumé)
Rappel de l'objectif configuré : `Départ 89,2 kg (12/01/2026) → Objectif 78,0 kg` + jauge de progression + « Modifier l'objectif » (→ Réglages, onglet Objectif).
### 8.5 Modale « Ajouter une pesée » (quick-add global)
Champs :
- **Poids (kg)** — numérique, pas 0,1, focus auto, pré-rempli avec la dernière valeur. Validation : 20300 kg.
- **Date** — datepicker, défaut aujourd'hui. **Heure** — optionnelle, défaut maintenant.
- **Note** — texte libre optionnel (placeholder : « ex. après le sport »).
- Section repliable **« Mensurations (optionnel) »** : Tour de taille, Hanches, Poitrine, Bras, Cuisse (cm, 1 décimale).
- Si une pesée existe déjà ce jour : avertissement « Une pesée existe déjà le 13/08/2026 (82,6 kg). Enregistrer remplacera cette valeur. » avec choix « Remplacer » / « Ajouter quand même ».
- Boutons : « Annuler » / « Enregistrer » → toast « Pesée enregistrée ✓ ».
---
## 9. Page — Nutrition
**Route** `/nutrition` · **Titre** « Nutrition » · **Période par défaut** : 30 j (graphes) ; le **journal** est journalier avec son propre navigateur de date.
Le budget kcal du jour vient de la Balance énergétique (§15.3) : `dailyBudget = TDEE targetDeficit`, ou valeur manuelle.
### 9.1 KPI (6 cartes)
| Libellé | Valeur | Sous-texte |
|---|---|---|
| **Aujourd'hui** | `1 450 / 1 800 kcal` | jauge + `Reste 350 kcal` |
| **Moyenne 7 j** | `1 720 kcal/j` | `Budget moyen : 1 800 kcal/j` |
| **Protéines aujourd'hui** | `96 / 130 g` | jauge (objectif §15.3) |
| **Répartition du jour** | `P 27 % · G 45 % · L 28 %` | en % des kcal |
| **Jours dans le budget** | `18 / 30 j` | sur la période sélectionnée |
| **Écart moyen au budget** | `80 kcal/j` | vert si négatif |
### 9.2 Graphiques
**G1 — « Calories par jour »** (principal, pleine largeur)
- Type : `bar` + `line`. Axe X jours, axe Y kcal (un seul axe — même unité).
- Série 1 `Apports` : barres, couleur par jour via callback `itemStyle.color` : `semantic-positive` si `kcal ≤ budgetOfDay`, `semantic-negative` sinon (comparaison au budget **du jour**, le budget peut évoluer).
- Série 2 `Budget` : ligne en escalier (`step: 'end'`), pointillés, `#C3C2B7`.
- Tooltip axe : `mar. 12/08 — Apports 1 940 kcal · Budget 1 800 kcal · Écart +140 kcal`. Zoom inside + slider. Légende (2 séries).
**G2 — « Macronutriments par jour »** (pleine largeur)
- Type : `bar` empilées, unité **kcal** (P ×4, G ×4, L ×9 — permet la comparaison visuelle avec G1). Séries : `Protéines` `#199E70`, `Glucides` `#C98500`, `Lipides` `#D55181` (espace 2 px entre segments). Légende. Tooltip axe avec grammes ET kcal : `Protéines 96 g (384 kcal)`. Toggle dans le menu ⋯ : « Afficher en grammes ».
**G3 — « Répartition du jour »** (tiers de largeur)
- Type : `pie` (donut, radius `['55%','80%']`). 3 parts P/G/L, mêmes couleurs que G2. Centre : `1 450 kcal` (graphic text). Labels directs : `Protéines 27 %`. Tooltip item : `Glucides — 652 kcal (45 %) · 163 g`. Suit la date du journal (§9.4).
**G4 — « Répartition par repas »** (deux tiers de largeur)
- Type : `bar` empilées 100 % (`stack` + normalisation), une barre par jour. Séries : `Petit-déjeuner` (slot 1), `Déjeuner` (2), `Dîner` (3), `Collations` (4). Tooltip : kcal réels + %. Légende. Objectif : voir si un repas dérape (grignotage).
**G5 — « Top aliments »** (pleine largeur, 320 px)
- Type : `bar` horizontales, top 10 des aliments par kcal totales sur la période. Axe Y : noms d'aliments (tronqués à 24 caractères + tooltip complet), axe X kcal. Une seule série → couleur unique `chart-2`, pas de légende. Label direct à droite de chaque barre : `12 340 kcal`. Tooltip item : `Pain complet — 12 340 kcal · 28 fois · 441 kcal/fois en moyenne`. Clic sur une barre → filtre le journal sur cet aliment (badge de filtre actif au-dessus du journal).
### 9.3 Bibliothèque d'aliments (panneau secondaire)
Accessible par un bouton « Mes aliments » dans l'en-tête de page : panneau latéral (drawer) listant les aliments mémorisés — `Nom` · `kcal/100 g` (ou /portion) · `P/G/L` · `Utilisé n fois` · actions ✎ 🗑. Recherche en tête. C'est cette bibliothèque qu'interroge l'autocomplete du quick-add (§9.5) ; les aliments importés de Foodvisor y sont ajoutés automatiquement (dédupliqués par nom normalisé).
### 9.4 Journal alimentaire (section « Journal du jour »)
- Navigateur de date : ` mer. 13/08/2026 ` + bouton « Aujourd'hui ». Swipe gauche/droite sur mobile.
- Bandeau du jour : `Total : 1 450 / 1 800 kcal · P 96 g · G 163 g · L 45 g` + jauge.
- 4 groupes repliables : **Petit-déjeuner / Déjeuner / Dîner / Collations**, chacun avec sous-total (`620 kcal`) et bouton « + Ajouter un aliment ».
- Ligne d'entrée : nom · quantité (`150 g`) · kcal · P/G/L (masqués sur mobile, visibles au tap) · actions ✎ / 🗑 / ⧉ (dupliquer vers un autre jour/repas).
- Les entrées importées de Foodvisor portent un badge source `Foodvisor` (tooltip : « Importé le 10/08/2026 »).
### 9.5 Modale « Ajouter un aliment » (quick-add global)
- **Recherche** (focus auto) : autocomplete sur la bibliothèque personnelle (aliments déjà saisis/importés), résultats avec kcal/100 g. Sélection → pré-remplit tout.
- **Nom** (texte, requis) · **Repas** (select : Petit-déjeuner / Déjeuner / Dîner / Collations — défaut selon l'heure : < 11 h petit-déj., 1115 h déjeuner, 1518 h collations, > 18 h dîner) · **Date** (défaut : jour affiché dans le journal).
- **Quantité** + **Unité** (`g` / `ml` / `portion`) — si l'aliment vient de la bibliothèque, kcal et macros se recalculent proportionnellement.
- **Calories (kcal)** (requis) · **Protéines (g)** · **Glucides (g)** · **Lipides (g)** (optionnels).
- Case « Mémoriser cet aliment dans ma bibliothèque » (cochée par défaut pour une saisie manuelle).
- Boutons : « Annuler » / « Ajouter » / « Ajouter et continuer » (garde la modale ouverte, vide la recherche — saisie en rafale d'un repas).
---
## 10. Page — Activité & Sport
**Route** `/activite` · **Titre** « Activité & Sport » · **Période par défaut** : 30 j.
Sources : Health Connect (pas, distance, kcal actives via bridge), FitShow (séances tapis), saisie manuelle. `stepsGoal` configurable (défaut 10 000).
### 10.1 KPI (6 cartes)
| Libellé | Valeur | Sous-texte |
|---|---|---|
| **Pas aujourd'hui** | `7 842 / 10 000` | jauge |
| **Moyenne pas (7 j)** | `9 120 /j` | `▲ +6 % vs 7 j précédents` |
| **Kcal actives aujourd'hui** | `320 kcal` | `Moyenne 7 j : 410 kcal/j` |
| **Distance (période)** | `86,4 km` | `≈ 2,9 km/j` |
| **Séances cette semaine** | `3 séances · 2 h 15` | `Semaine dernière : 4 · 3 h 05` |
| **Objectif atteint** | `21 / 30 j` | jours ≥ objectif de pas sur la période |
### 10.2 Graphiques
**G1 — « Pas par jour »** (principal, pleine largeur)
- Type : `bar` + `line`. Série `Pas` : barres `#199E70` (`chart-3`) ; jours ≥ objectif : opacité 100 %, sinon 55 % (renfort non-couleur : l'axe le montre aussi). Série `Moyenne 7 j` : ligne 2 px blanche à 60 %. `markLine` objectif (« Objectif 10 000 »). Tooltip axe, zoom inside + slider. Légende (2 séries).
**G2 — « Calories actives par jour »** (demi-largeur)
- Type : `bar`, série unique `#D95926` (`chart-2`), pas de légende. `markLine` moyenne de période (label « Moy. 410 »). Tooltip axe.
**G3 — « Entraînement par semaine »** (demi-largeur)
- Type : `bar` empilées par semaine ISO, axe Y **heures** (`1 h 30` au tooltip). Séries = types de séance : `Tapis de course` (1), `Marche` (2), `Vélo` (3), `Renforcement` (4), `Autre` (5, muted). Légende. Tooltip axe : détail par type + total semaine.
**G4 — « Distance cumulée »** (pleine largeur, 240 px)
- Type : `line` en aire, cumul de la distance (pas + séances) depuis le début de la période, `#3987E5` avec dégradé. Label direct sur le dernier point (`86,4 km`). Tooltip axe : `12/08 — cumul 84,1 km (+2,3 km ce jour)`.
### 10.3 Carte « Records » (stat tiles, pas un graphique)
4 tuiles : **Max pas en un jour** `18 452 · 21/06/2026` · **Plus longue séance** `1 h 32 · tapis · 05/07/2026` · **Meilleure distance en séance** `12,4 km` · **Meilleure semaine** `78 500 pas · Sem. 25`. Chaque record est cliquable → surligne le jour dans G1.
### 10.4 Table « Séances »
Colonnes : `Date` · `Type` (badge icône) · `Durée` · `Distance` · `Kcal` · `FC moy.` (si dispo) · `Source` (badge : FitShow / Health Connect / Manuel) · actions ✎ 🗑. Filtres : type, source. Tri par date desc.
### 10.5 Modale « Ajouter une séance »
Champs : **Type** (select : Tapis de course / Marche / Course à pied / Vélo / Renforcement / Natation / Autre) · **Date** (défaut aujourd'hui) · **Heure de début** · **Durée** (champ `hh:mm`, requis) · **Distance (km)** (optionnel) · **Calories (kcal)** (optionnel — placeholder « estimées automatiquement si vide », estimation MET simple par type) · **FC moyenne (bpm)** (optionnel) · **Note**. Boutons « Annuler » / « Enregistrer ».
---
## 11. Page — Balance énergétique
**Route** `/balance` · **Titre** « Balance énergétique » · **Période par défaut** : 30 j.
Définitions (constantes en `src/lib/energy.ts`) :
- `BMR` : Mifflin-St Jeor (`10×kg + 6.25×cm 5×âge + s`, `s = +5` homme / `161` femme), recalculé chaque jour avec le poids de tendance.
- `TDEE = BMR × activityFactor` (§15.1) **ou** `BMR + kcal actives mesurées` si la source d'activité est complète (choix dans Réglages : `tdeeMode: 'factor' | 'measured'`).
- `netBalance(day) = kcalIn TDEE(day)`. **Négatif = déficit = vert.**
- `KCAL_PER_KG = 7700` pour toutes les conversions kcal ↔ kg.
- Un jour sans journal alimentaire est **exclu** des cumuls (et hachuré dans les graphes) plutôt que compté à 0 — règle anti-fausses-données.
### 11.1 KPI (6 cartes)
| Libellé | Valeur | Sous-texte |
|---|---|---|
| **Balance aujourd'hui** | `520 kcal` | `Apports 1 450 · Dépense 1 970` |
| **Déficit moyen (7 j)** | `430 kcal/j` | `Cible : 550 kcal/j` |
| **Cumul (période)** | `12 450 kcal` | `≈ 1,6 kg théoriques` |
| **TDEE estimé** | `2 350 kcal/j` | `BMR 1 780 × 1,32` (ou `BMR + actives mesurées`) |
| **Budget quotidien** | `1 800 kcal` | `TDEE 550` |
| **Réel vs théorique** | `+0,4 kg` | `le réel décroche au-dessus du modèle` (voir G4) |
### 11.2 Graphiques
**G1 — « Entrées vs sorties »** (principal, pleine largeur)
- Type : `bar` **en miroir** sur un seul axe kcal : série `Apports` en valeurs positives (`#D95926`, `chart-2`), série `Dépense énergétique` en valeurs négatives (`#3987E5`, `chart-1`, arrondis vers le bas). Ligne zéro marquée (`axisLine` y=0 en `#383835`).
- Tooltip axe : `mar. 12/08 — Apports 1 940 · Dépense 2 310 · Balance 370 kcal` (balance colorée). Légende (2 séries). Zoom inside + slider. Jours sans journal : barres hachurées (`decal`) + mention tooltip « journal incomplet ».
**G2 — « Balance nette quotidienne »** (demi-largeur)
- Type : `bar`, une série `net = in out`. `visualMap.pieces` : `< 0``semantic-positive` (déficit), `> 0``semantic-negative` (surplus). `markLine` à la cible de déficit (`550`, pointillés). Tooltip : `12/08 — Balance 370 kcal (déficit)`.
**G3 — « Déficit cumulé »** (demi-largeur)
- Type : `line` en aire, cumul de `net` depuis le début de période. Couleur `semantic-positive` si le cumul est négatif (cas normal), l'aire se remplit **sous** zéro. Axe Y kcal ; le tooltip donne la double lecture : `Cumul 12 450 kcal ≈ 1,6 kg` (pas de second axe — l'équivalence kg vit dans le tooltip et le KPI).
**G4 — « Poids théorique vs poids réel »** (pleine largeur)
- Type : `line`, 2 séries, axe Y kg (même unité, un seul axe) :
1. `Poids réel (tendance)``#3987E5`, 2 px ;
2. `Poids théorique``#9085E9` (`chart-7`), pointillés `[6,4]` : `startWeight + cumul(net)/7700`, ancré sur la tendance au 1er jour de la période.
- Légende + labels directs en bout de lignes. Tooltip axe : les deux valeurs + `Écart +0,4 kg`. C'est **le** graphique de vérité du module : si le réel et le théorique divergent durablement, le TDEE est recalibré (voir carte méthode).
### 11.3 Carte « Méthode & calibration »
Carte informative repliable : explication en 3 phrases du modèle (BMR Mifflin-St Jeor, TDEE, 7 700 kcal/kg), et ligne de calibration : « Sur les 30 derniers jours, votre dépense réelle estimée d'après la pesée est de **2 410 kcal/j** (modèle : 2 350). » + bouton « Utiliser cette valeur comme TDEE » (écrit un override dans Réglages).
### 11.4 État dégradé
Cette page exige **pesées + journal alimentaire**. S'il manque l'un des deux sur la période : bandeau jaune « Données incomplètes : X jours sans journal alimentaire sur la période. Les cumuls excluent ces jours. »
---
## 12. Page — Vape
**Route** `/vape` · **Titre** « Vape » · **Période par défaut** : 30 j.
Modèle de coût (constantes dans Réglages §15.4) :
- `costPerMl` = (prix base/ml + prix nicotine/ml au taux cible + prix arôme/ml au dosage) — détail affiché en §12.4.
- `coilCostPerDay` = prix résistance ÷ durée de vie moyenne (jours).
- `vapeCostPerDay(d) = ml(d) × costPerMl + coilCostPerDay`.
- `tobaccoCostPerDay` = (cigarettes/jour avant arrêt ÷ cigarettes/paquet) × prix du paquet — **figé à la config baseline**, avec historique de prix optionnel.
- `savings(d) = Σ depuis quitDate (tobaccoCostPerDay vapeCostPerDay)`.
- `avoidedCigarettes = jours depuis quitDate × cigarettes/jour avant` (compteur temps réel au prorata de la journée).
- Consommation ml/jour : dérivée des **recharges** (une recharge de 10 ml le 12/08 puis une le 15/08 ⇒ ~3,3 ml/j lissés sur l'intervalle) ; une saisie quotidienne directe est aussi possible — les deux modes coexistent, la donnée quotidienne prime.
### 12.1 KPI (8 cartes, 2 rangées)
| Libellé | Valeur | Sous-texte |
|---|---|---|
| **Aujourd'hui** | `3,8 ml` | `Moyenne 7 j : 4,1 ml/j` |
| **Nicotine aujourd'hui** | `11,4 mg` | `3,8 ml × 3,0 mg/ml` |
| **Coût moyen / jour** | `0,62 €/j` | `dont résistances 0,17 €/j` |
| **Économies cumulées** | `1 245,80 €` | `vs 8,50 €/j de tabac` — toujours vert |
| **Cigarettes évitées** | `7 654` | compteur animé (odometer), `≈ 15/j` |
| **Sans tabac depuis** | `511 jours` | `depuis le 15/03/2025` |
| **Résistance actuelle** | `J+9` | `Moyenne : 12 j — à surveiller` (badge jaune si ≥ moyenne 2 j, rouge si > moyenne) |
| **Stock estimé** | `34 ml` | `≈ 8 jours restants` (si suivi de stock activé, sinon carte masquée) |
### 12.2 Graphiques
**G1 — « Consommation d'e-liquide »** (principal, pleine largeur)
- Type : `line` + `scatter`. Série `ml/jour` : points `#9085E9` (`chart-7`) à 45 % ; série `Moyenne mobile 7 j` : ligne 2 px `#9085E9`. `markLine` optionnelle « Objectif » si un objectif de réduction est défini (§15.4). Tooltip axe : `12/08 — 4,2 ml · moyenne 7 j : 4,0 ml`. Zoom inside + slider. Légende (2 séries).
- Les jours de **changement de résistance** apparaissent en `markPoint` discrets (petit pictogramme ⚙ sous l'axe, tooltip « Résistance changée »).
**G2 — « Nicotine absorbée »** (demi-largeur)
- Type : `bar`, série unique `mg/jour` `#D55181` (`chart-5`). `markLine` moyenne de période. Tooltip : `12/08 — 12,6 mg (4,2 ml × 3,0 mg/ml)`. Si le taux a changé dans la période, chaque jour utilise le taux effectif de la recharge courante.
**G3 — « Coût quotidien »** (demi-largeur)
- Type : `line`, série `€/jour` `#C98500` (`chart-4`), moyenne mobile 7 j (le brut en points 45 %). `markLine` de référence : `Tabac : 8,50 €/j` (pointillés `#C3C2B7`) — l'écart visuel entre la ligne et la markLine **est** l'économie quotidienne. Tooltip : `12/08 — vape 0,66 € · tabac évité 8,50 € · gain 7,84 €`.
**G4 — « Économies cumulées »** (pleine largeur, 320 px — le graphique plaisir)
- Type : `line` en aire, cumul `savings` depuis `quitDate`, couleur `#0CA30C` avec dégradé d'aire. Période forcée « Tout » par défaut (surcharge locale possible). Label direct sur le dernier point : `1 245,80 €`. `markPoint` aux paliers franchis : `100 €`, `250 €`, `500 €`, `1 000 €`, `2 000 €`, `5 000 €` (pin avec étiquette). Tooltip axe : `12/08 — 1 238,40 € économisés`. Zoom inside + slider.
**G5 — « Durée de vie des résistances »** (pleine largeur, 260 px)
- Type : `bar`, une barre par résistance **terminée**, axe X = date de changement (catégorie), axe Y = durée en jours. Couleur unique `chart-7` ; la résistance courante (en cours) apparaît en dernière barre hachurée (`decal`) avec sa durée provisoire. `markLine` moyenne (label « Moy. 12 j »). Tooltip item : `Changée le 04/08/2026 — a duré 13 j · ~52 ml vapotés · GT Mesh 0,15 Ω`.
### 12.3 Timeline « Jalons santé » (composant liste, pas ECharts)
Timeline verticale (rail à gauche, points verts = atteint, gris = à venir, le prochain jalon porte une jauge de progression). Jalons depuis `quitDate` (libellés exacts) :
| Échéance | Libellé |
|---|---|
| 20 minutes | « Pouls et tension redescendent » |
| 8 heures | « Le monoxyde de carbone diminue de moitié » |
| 24 heures | « Le CO est éliminé, les poumons évacuent les résidus » |
| 48 heures | « Goût et odorat s'améliorent » |
| 72 heures | « Respirer devient plus facile » |
| 2 semaines | « La circulation sanguine s'améliore » |
| 1 mois | « La peau retrouve son éclat » |
| 3 mois | « Toux et fatigue diminuent, souffle nettement meilleur » |
| 6 mois | « Capacité pulmonaire en nette hausse » |
| 1 an | « Risque d'infarctus réduit de moitié » |
| 5 ans | « Risque d'AVC redevenu comparable à un non-fumeur » |
| 10 ans | « Risque de cancer du poumon réduit de moitié » |
### 12.4 Carte « Modèle de coût » (repliable)
Décomposition affichée : `Base PG/VG 0,08 €/ml + Nicotine 0,11 €/ml + Arôme 0,09 €/ml = 0,28 €/ml` · `Résistance : 3,90 € ÷ 12 j = 0,33 €/j` · `Référence tabac : 15 cig./j × (12,50 € / 20) = 9,38 €/j` (valeurs = exemples). Bouton « Modifier le modèle » → Réglages §15.4.
### 12.5 Modale « Recharge » (quick-add global)
Champs : **Quantité (ml)** — numérique, défaut = contenance de flacon par défaut (§15.4), boutons rapides `10` `30` `50` · **Taux de nicotine (mg/ml)** — défaut = taux courant des Réglages · **Date et heure** — défaut maintenant · **Arôme / recette** (texte optionnel, autocomplete sur les précédents) · **Note**. Boutons « Annuler » / « Enregistrer » → toast « Recharge de 10,0 ml enregistrée ✓ ».
Variante dans la même modale (onglets) : **« Conso du jour »** — saisie directe `ml` pour une date (corrige/remplace l'estimation par recharges).
### 12.6 Bouton « Résistance changée » (action un clic)
Depuis le menu « + Ajouter », la page Vape (bouton dédié dans l'en-tête de G5) ou le tableau de bord : enregistre `coilChange(now)` immédiatement, toast « Résistance changée ✓ — la précédente a duré 13 jours » + lien « Ajouter un détail » (ouvre une mini-modale : Modèle (texte, autocomplete), Valeur (Ω), Prix unitaire (défaut Réglages), Note).
### 12.7 Tables
- **« Historique des recharges »** : `Date` · `Quantité` · `Nicotine` · `Arôme` · `Coût estimé` · actions ✎ 🗑.
- **« Historique des résistances »** : `Posée le` · `Retirée le` · `Durée` · `Conso (ml)` · `Modèle` · `Prix` · actions ✎ 🗑.
---
## 13. Page — Finances
**Route** `/finances` · **Titre** « Finances » · **Période par défaut** : **Ce mois-ci** (le `PeriodSelector` affiche ici en tête : `Ce mois-ci` · `3 mois` · `6 mois` · `1 an` · `Tout` · `Personnalisé`).
La page est organisée en 4 onglets internes (tabs sous la topbar) : **Vue d'ensemble · Transactions · Budgets · Récurrents**. L'URL les reflète (`/finances`, `/finances/transactions`, `/finances/budgets`, `/finances/recurrents`).
### 13.1 KPI (communs, affichés sur « Vue d'ensemble »)
| Libellé | Valeur | Sous-texte |
|---|---|---|
| **Solde total** | `4 812,34 €` | `3 comptes` (au dernier import) |
| **Dépenses du mois** | `1 234,56 €` | `Moyenne 6 mois : 1 480,00 €` |
| **Revenus du mois** | `2 300,00 €` | |
| **Cashflow du mois** | `+1 065,44 €` | vert si > 0, rouge sinon |
| **Budget global** | `82 %` | jauge `1 234,56 € / 1 500,00 €` |
| **À catégoriser** | `14 transactions` | carte cliquable → onglet Transactions filtré `catégorie = aucune` (badge jaune si > 0) |
### 13.2 Onglet « Vue d'ensemble » — graphiques
**G1 — « Évolution du solde total »** (pleine largeur)
- Type : `line` en aire, solde total reconstitué jour par jour (somme des comptes), `#3987E5`. Tooltip axe : `12/08 — 4 812,34 €`. Zoom inside + slider. Sous le graphique, rangée de **tuiles par compte** : nom du compte, solde `1 852,10 €`, date de dernière donnée (badge jaune « Dernier import il y a 32 j » si > 30 j).
**G2 — « Dépenses par catégorie »** (demi-largeur, période courante)
- Type : `pie` donut (radius `['50%','78%']`), top 7 catégories + part `Autres` (`#898781`). Couleur = slot fixe de chaque catégorie (§4.4). Centre : total `1 234,56 €`. Labels directs : `Alimentation 24 %`. Tooltip item : `Alimentation — 296,40 € (24 %) · 18 transactions`. **Clic sur une part → onglet Transactions filtré sur la catégorie + la période.**
**G3 — « Dépenses mensuelles par catégorie »** (demi-largeur)
- Type : `bar` empilées, 12 derniers mois, mêmes couleurs de catégories (top 7 + Autres, espaces 2 px). Légende. Tooltip axe : détail par catégorie + total du mois. Mois courant hachuré (`decal`, incomplet). Clic sur un segment → Transactions filtrées (mois + catégorie).
**G4 — « Cashflow mensuel »** (pleine largeur)
- Type : `bar` groupées + `line`, un seul axe € : `Revenus` (barres `semantic-positive`), `Dépenses` (barres `semantic-negative`, affichées en positif, côte à côte), `Net` (ligne blanche 2 px, peut passer sous zéro). Légende (3). Tooltip axe : `juin 2026 — Revenus 2 300,00 € · Dépenses 1 890,50 € · Net +409,50 €`.
**G5 — « Flux du mois »** (pleine largeur, 380 px)
- Type : `sankey`, orientation gauche → droite : nœuds `Revenus` (par catégorie de revenu : Salaire, Autres revenus) → nœud central `Budget du mois` → catégories de dépenses (top 7 + Autres) → l'écart restant sort vers un nœud `Épargne` (vert) si positif. Liens colorés par catégorie cible à 40 % d'opacité. Tooltip lien : `Budget → Alimentation : 296,40 €`. Nœuds : label + montant. Pas de zoom ; drag des nœuds désactivé (`draggable: false`).
### 13.3 Onglet « Transactions »
**Barre de filtres** (une ligne, wrap mobile) : Recherche texte (libellé) · Compte (multi-select) · Catégorie (multi-select avec « Sans catégorie ») · Type (`Tout / Dépenses / Revenus`) · Montant min/max · période (héritée de la page). Badges de filtres actifs effaçables ; bouton « Réinitialiser ».
**Table** (le cœur du module) — colonnes :
| Colonne | Contenu |
|---|---|
| `Date` | `dd/MM/yyyy` (groupes visuels par mois au scroll : sous-en-tête « août 2026 — 1 234,56 € de dépenses ») |
| `Libellé` | libellé bancaire nettoyé, sous-ligne : libellé brut original en 11 px muted |
| `Compte` | badge court (« Courant », « PayPal ») |
| `Catégorie` | **éditable en ligne** : badge coloré (pastille couleur du slot) cliquable → combobox avec recherche ; `Sans catégorie` = badge jaune pointillé |
| `Montant` | aligné droite, tabular ; dépenses `45,90 €` en `text-ink`, revenus `+2 300,00 €` en `semantic-positive` |
| Actions (hover / menu ⋯) | ✎ Modifier · ⚡ **« Créer une règle »** · 🗑 Supprimer (les transactions importées se suppriment avec confirmation renforcée) |
- Sélection multiple (checkbox) → barre d'actions groupées : « Catégoriser (n) », « Supprimer (n) ».
- Une transaction re-catégorisée à la main affiche une pastille « manuel » (les règles ne l'écrasent plus).
**Modale « Créer une règle »** (ouverte depuis une transaction, pré-remplie) :
- **Si le libellé contient** (texte, pré-rempli avec le mot significatif du libellé, ex. `CARREFOUR`) — select d'opérateur : `contient` / `commence par` / `expression régulière`.
- Conditions optionnelles (ajout par bouton « + Condition ») : **Compte est** (select) · **Montant entre** (min/max) · **Type** (dépense/revenu).
- **Alors catégoriser en** : combobox catégorie (+ « Créer une catégorie… » inline).
- Aperçu en direct : « **12 transactions existantes** correspondent à cette règle » + mini-liste des 5 premières.
- Case « Appliquer aux transactions existantes non catégorisées manuellement » (cochée par défaut).
- Boutons « Annuler » / « Créer la règle ». Toast : « Règle créée ✓ — 12 transactions catégorisées ».
### 13.4 Onglet « Budgets »
- Sélecteur de mois (` août 2026 `).
- Liste de **barres de progression par catégorie** (composant, pas ECharts) : pastille + nom · `296,40 € / 350,00 €` · barre 8 px (vert < 80 %, jaune 80100 %, rouge > 100 % — le dépassement déborde en rouge au-delà de 100 % avec largeur plafonnée et libellé `118 %`) · reste `53,60 €` ou `Dépassé de 22,40 €`.
- En tête : budget global (somme) avec la même barre + **« Rythme »** : `Au 13/08, vous avez dépensé 82 % du budget pour 42 % du mois écoulé` (jauge à double repère : position du jour vs consommation).
- Graphique **G6 — « Budget vs réalisé (6 mois) »** : `bar` groupées par mois : `Budget` (barres `#898781` 40 %) vs `Dépensé` (barres `chart-1`), un axe €. Légende. Tooltip axe.
- Bouton « Modifier les budgets » → Réglages §15.5 (ou édition inline du montant au clic).
### 13.5 Onglet « Récurrents »
- Détection automatique : transactions au libellé similaire, périodicité ~mensuelle/hebdo/annuelle (tolérance ±4 j), ≥ 3 occurrences.
- **Table « Dépenses récurrentes détectées »** : `Libellé` · `Catégorie` · `Fréquence` (`Mensuel`, `Annuel`…) · `Montant moyen` · `Dernière occurrence` · `Prochaine échéance estimée` (badge jaune si dépassée = possible résiliation ou changement) · `Coût annuel` (tri par défaut, desc) · actions : « Confirmer » / « Ignorer » (les ignorés vont dans un panneau repliable).
- KPI d'onglet : **« Total récurrent mensuel »** `184,90 €/mois` · **« Part des dépenses »** `15 %` · **« Coût annuel »** `2 218,80 €`.
- Graphique **G7 — « Échéancier du mois »** : frise horizontale du mois (composant custom léger) avec les échéances positionnées au jour, passées en plein / à venir en contour.
### 13.6 Modale « Ajouter une transaction » (manuelle)
Champs : **Type** (toggle Dépense / Revenu) · **Montant (€)** · **Date** · **Libellé** · **Compte** (select) · **Catégorie** (combobox) · **Note**. Utilisée pour les espèces ; badge source « Manuel ».
---
## 14. Page — Imports
**Route** `/imports` · **Titre** « Imports » · pas de `PeriodSelector`.
### 14.1 Zone d'upload (carte principale)
- **Drag & drop** pleine largeur : pointillés `border`, icône `upload-cloud`, texte : « **Glissez un fichier ici** ou cliquez pour parcourir » · sous-texte : « CSV, OFX, XLSX ou ZIP — 20 Mo max ». Multi-fichiers accepté (file d'attente).
- **Choix du profil de source** (select, avec auto-détection : le parseur tente de reconnaître en-têtes/format et pré-sélectionne, badge « détecté automatiquement ») :
- `Health Connect — export (zip/csv)` — pas, poids, distance, kcal actives, séances
- `Foodvisor — export` — journal alimentaire
- `FitShow — export` — séances tapis
- `Relevé bancaire — CSV générique` (avec étape de mappage)
- `Relevé bancaire — OFX`
- `PayPal — activité (CSV)`
- `Pesées — CSV générique` (date; poids[; note])
- `Vape — CSV générique` (date; ml[; mg/ml])
- Flux en 3 étapes (stepper « 1. Fichier → 2. Vérification → 3. Import ») :
1. **Fichier** : upload + profil (+ pour les profils bancaires : select **Compte de destination**, avec « Créer un compte… » inline).
2. **Vérification** : pour le CSV générique, écran de **mappage de colonnes** (aperçu des 5 premières lignes ; pour chaque colonne cible — Date, Libellé, Montant (ou Débit/Crédit séparés), etc. — un select de colonne source ; format de date détecté ; option « ignorer la première ligne ») ; le mappage est mémorisé par profil+banque. Puis **aperçu** : table des 20 premières lignes normalisées + bandeau : « 254 lignes lues · **12 doublons ignorés** (déjà importés) · 2 lignes en erreur ».
3. **Import** : barre de progression, puis récapitulatif.
- Déduplication : hash par ligne normalisée (source + date + montant + libellé, ou date+valeur pour les mesures) — les ré-imports du même fichier sont sans effet (idempotent).
### 14.2 Table « Historique des imports »
Colonnes : `Date` (`13/08/2026 19:32`) · `Fichier` (nom + taille) · `Profil` (badge) · `Lignes` (`254`) · `Importées` (`240` en vert) · `Doublons` (`12` en muted) · `Erreurs` (`2` — badge rouge cliquable) · `Statut` (`Terminé` vert / `Partiel` jaune / `Échec` rouge / `Annulé` muted) · actions : « Détails », **« Annuler cet import »** (rollback : supprime les lignes créées par cet import, avec confirmation « Cette action supprimera les 240 entrées créées par cet import. »).
### 14.3 Panneau « Erreurs » (détail d'un import)
Ouvert depuis la table : liste des lignes en erreur — `N° de ligne` · `Contenu brut` (mono, tronqué) · `Motif` en français (« Date invalide : "31/02/2026" », « Montant illisible : "12,3O" », « Colonne "Poids" absente »). Bouton « Télécharger le rapport d'erreurs (CSV) ».
### 14.4 Carte « Connecteurs & API »
Rappel : « Votre téléphone peut envoyer les données Health Connect automatiquement via l'application compagnon. » + statut de la dernière synchronisation (`Dernière réception : 13/08/2026 07:12 · 4 types de données`) + bouton « Gérer les clés d'appareil » → Réglages §15.6.
---
## 15. Page — Réglages
**Route** `/reglages` · **Titre** « Réglages » · navigation par onglets verticaux (desktop) / accordéon (mobile) : **Profil · Objectif · Nutrition · Vape · Finances · Appareils & API · Application**. Chaque onglet = cartes de formulaires avec bouton « Enregistrer » par carte (toast de confirmation).
### 15.1 Onglet « Profil » (profil corporel)
- **Prénom / pseudonyme** (affichage) · **Sexe** (radio : Homme / Femme — utilisé pour le BMR) · **Date de naissance** (datepicker) · **Taille (cm)**.
- **Niveau d'activité** (select avec descriptions) : `Sédentaire (×1,2)` · `Légèrement actif (×1,375)` · `Modérément actif (×1,55)` · `Très actif (×1,725)` · `Extrêmement actif (×1,9)`.
- **Mode de calcul du TDEE** (radio) : « Facteur d'activité (recommandé au départ) » / « BMR + calories actives mesurées » / « Valeur manuelle : ___ kcal/j » (renseignée aussi par la calibration §11.3).
- Encart calculé en direct : `BMR : 1 780 kcal · TDEE estimé : 2 350 kcal/j`.
### 15.2 Onglet « Objectif »
- **Poids de départ** (pré-rempli : première pesée) et **date de départ**.
- **Poids cible (kg)** · **Rythme visé** (slider `0,25 — 1,0 kg/semaine`, graduations 0,25 · repère « recommandé : 0,5 ») ;
- Lignes calculées en direct : `Déficit quotidien nécessaire : ≈ 550 kcal/j` · `Date d'atteinte estimée : 24/10/2026` ;
- Alternative : saisir une **date cible** → le rythme et le déficit se recalculent (les trois champs sont liés, le dernier modifié gagne).
- Garde-fou : si le budget résultant < 1 200 kcal/j (femme) / 1 500 (homme) : avertissement jaune « Ce rythme impose un budget très bas. Envisagez un objectif plus progressif. »
### 15.3 Onglet « Nutrition »
- **Budget calorique** (radio) : « Automatique : TDEE déficit (1 800 kcal/j actuellement) » / « Manuel : ___ kcal/j ».
- **Objectifs de macros** : Protéines (`g/kg de poids` — défaut 1,6 — ou g fixes), Glucides / Lipides en % des kcal restantes (deux sliders liés).
- **Repas** : liste réordonnable des repas du journal (défaut : Petit-déjeuner, Déjeuner, Dîner, Collations) — renommage possible.
- **Objectif de pas** (utilisé page Activité) : défaut `10 000`.
### 15.4 Onglet « Vape »
Carte **« Référence tabac (avant l'arrêt) »** :
- **Date d'arrêt du tabac** (datepicker — ancre des économies et jalons) · **Cigarettes par jour** (défaut 15) · **Cigarettes par paquet** (défaut 20) · **Prix du paquet (€)** (défaut 12,50) — ligne calculée : `Coût tabac de référence : 9,38 €/j`.
Carte **« Modèle de coût DIY »** :
- **Base PG/VG** : prix (€) + contenance (ml) → `€/ml` calculé.
- **Booster de nicotine** : prix du flacon (€), contenance (ml, défaut 10), concentration (mg/ml, défaut 20).
- **Taux de nicotine cible (mg/ml)** de la préparation (défaut 3,0) → part booster calculée.
- **Arôme** : prix (€), contenance (ml), **dosage ( %)** (défaut 10 %).
- **Résistances** : prix unitaire (€) (+ prix du pack et nombre, au choix).
- **Contenance de flacon par défaut (ml)** pour le quick-add recharge (défaut 10).
- Encart calculé : `Coût de revient : 0,28 €/ml · avec résistances : ≈ 0,62 €/j au rythme actuel`.
- **Objectif de réduction** (optionnel) : `ml/jour visés` et/ou `taux nicotine visé` → matérialisés en markLine sur G1/G2 de la page Vape.
### 15.5 Onglet « Finances »
Quatre cartes :
1. **Comptes** — table CRUD : `Nom` (« Compte courant ») · `Type` (Courant / Épargne / PayPal / Espèces / Autre) · `Banque` · `Solde initial` + `Date du solde initial` · `Devise` (EUR fixe v1) · actions ✎ 🗑 (suppression bloquée si transactions liées — proposer l'archivage).
2. **Catégories** — liste hiérarchique (2 niveaux max) : pastille couleur (slot proposé automatiquement, modifiable parmi les 8), icône (picker), nom ; catégories par défaut livrées : Alimentation, Logement, Transports, Santé, Loisirs, Abonnements, Restaurants, Shopping, Vape/Tabac, Épargne, Revenus (type revenu), Autres. Drag pour réordonner.
3. **Règles de catégorisation** — table : `Priorité` (drag & drop, la première règle qui matche gagne) · `Condition` (résumé lisible : « Libellé contient "CARREFOUR" ») · `Catégorie` · `Nb de transactions touchées` · actif (switch) · actions ✎ 🗑. Bouton « Tester les règles » : ré-exécute sur les non-catégorisées (aperçu avant application).
4. **Budgets mensuels** — table : catégorie · montant €/mois (input inline) · switch « reporter le non-dépensé » (v2, désactivé) ; ligne de total.
### 15.6 Onglet « Appareils & API »
- Texte d'intro : « Créez une clé pour permettre à l'application compagnon Android (Health Connect) d'envoyer ses données à LifeTrack. »
- **Table des clés** : `Nom` (« Pixel de Julien ») · `Préfixe` (`ltk_a3f4…`) · `Portées` (badges : `health:write`, `weight:write`…) · `Créée le` · `Dernière utilisation` (« il y a 2 h » — vert si < 24 h) · actions : 🗑 « Révoquer » (confirmation).
- **Bouton « Créer une clé »** → modale : Nom, portées (checkboxes), puis écran d'affichage **unique** de la clé complète avec bouton copier + avertissement « Cette clé ne sera plus jamais affichée. »
- Encart développeur repliable : méthode + URL du endpoint d'ingestion (`POST /api/v1/ingest`), en-tête `Authorization: Bearer <clé>`, exemple de payload JSON minimal.
- **Journal de synchronisation** : 20 dernières réceptions (date, appareil, types de données, nb d'enregistrements, statut).
### 15.7 Onglet « Application »
- **Thème** : Sombre (défaut) / Clair / Automatique. · **Langue** : Français (v1). · **Fuseau horaire** : Europe/Paris (info, non modifiable v1). · **Premier jour de la semaine** : Lundi.
- **Accessibilité** : switch « Motifs et textures sur les graphiques » (active les textures ECharts `decal` pour daltonisme/impression).
- **Sauvegarde** : « Exporter toutes mes données (JSON) » · « Exporter (CSV par module, ZIP) ».
- **Zone dangereuse** (bordure rouge) : « Vider un module… » (select module + confirmation par saisie du mot SUPPRIMER) · « Supprimer le compte ».
- **Utilisateurs** (préparation multi-utilisateur) : table des utilisateurs (v1 : le seul admin) + bouton « Inviter » désactivé avec badge « bientôt ».
### 15.8 Assistant de premier lancement (first-run wizard)
Plein écran, 4 étapes, jamais bloquant (« Passer » partout sauf étape 1) :
1. **« Créez votre compte administrateur »** — e-mail, mot de passe (×2, jauge de robustesse).
2. **« Votre profil »** — sexe, naissance, taille, poids actuel, niveau d'activité (§15.1).
3. **« Votre objectif »** — poids cible + rythme (§15.2), affichage immédiat du budget kcal.
4. **« Vos modules »** — 3 cartes activables : Vape (si activée → mini-formulaire baseline tabac §15.4), Finances (créer le premier compte), Import (lien vers `/imports`). Bouton final : « C'est parti → » (vers le tableau de bord).
---
## 16. États vides
Chaque page/section a un `EmptyState` (§5.4) avec ce texte exact :
| Contexte | Titre | Texte | Actions |
|---|---|---|---|
| Tableau de bord (tout vide) | « Bienvenue sur LifeTrack 👋 » | « Configurez votre profil et importez vos premières données pour voir vos tableaux de bord prendre vie. » | « Configurer mon profil » · « Importer des données » |
| Poids | « Aucune pesée pour l'instant » | « Ajoutez votre première pesée ou importez un historique — la tendance et les projections apparaîtront dès 3 pesées. » | « + Ajouter une pesée » · « Importer un CSV » |
| Nutrition (journal du jour) | « Rien dans le journal aujourd'hui » | « Ajoutez votre premier aliment ou importez votre historique Foodvisor depuis la page Imports. » | « + Ajouter un aliment » · « Importer Foodvisor » |
| Activité | « Aucune activité enregistrée » | « Connectez l'application compagnon Health Connect, importez un export FitShow, ou saisissez une séance manuellement. » | « + Ajouter une séance » · « Voir les imports » |
| Balance énergétique | « Il manque des données » | « La balance énergétique a besoin de vos pesées **et** de votre journal alimentaire. Complétez ces deux modules pour débloquer cette page. » | « Aller à Poids » · « Aller à Nutrition » |
| Vape | « Module vape non configuré » | « Renseignez votre consommation de cigarettes avant l'arrêt et votre modèle de coût : LifeTrack calculera vos économies au centime près. » | « Configurer la vape » |
| Vape (configurée, sans données) | « Aucune recharge enregistrée » | « Enregistrez votre première recharge d'e-liquide — deux clics suffisent. » | « + Recharge » |
| Finances | « Aucune transaction » | « Importez un relevé bancaire (CSV ou OFX) ou un export PayPal pour démarrer. La catégorisation automatique fera le tri. » | « Importer un relevé » |
| Finances → Récurrents | « Pas encore de récurrents détectés » | « La détection a besoin d'au moins 3 mois de transactions pour repérer vos abonnements et charges fixes. » | — |
| Imports (historique) | « Aucun import pour l'instant » | « Déposez un fichier ci-dessus : LifeTrack détecte le format et vous montre un aperçu avant d'importer quoi que ce soit. » | — |
| Table filtrée sans résultat | « Aucun résultat » | « Aucune ligne ne correspond à ces filtres. » | « Réinitialiser les filtres » |
| Graphique sans données sur la période | (dans le canvas) « Pas de données sur cette période » | — | « Élargir la période » (passe à `Tout`) |
---
## 17. Accessibilité
- **Palette validée** (script de validation CVD — voir §4.4) ; l'ordre des slots ne doit pas être modifié sans re-validation.
- La couleur n'est jamais seule : légendes systématiques dès 2 séries, signes `+`/``, icônes de tendance, labels directs sélectifs, badges texte sur les statuts.
- **« Voir les données »** sur chaque ChartCard = équivalent tabulaire complet (triable, exportable CSV).
- Option « Motifs et textures » (§15.7) : applique des hachures `decal` à 45°/135° sur les séries des graphiques empilés et miroirs.
- Navigation clavier complète : sidebar et tabs focusables, modales avec focus trap, `Esc` ferme, tables navigables aux flèches.
- Contrastes : textes ≥ 4,5:1 sur `surface` ; les 8 slots de série ≥ 3:1 sur `#1A1A19`.
- `prefers-reduced-motion` : désactive l'animation des graphiques et le compteur odometer (affichage direct).
- Tooltips ECharts doublés d'un `aria-label` descriptif par graphique (« Graphique en ligne : évolution du poids du 15/05 au 13/08, de 85,1 à 82,4 kg »).
---
*Fin de la spécification UX. Toute divergence d'implémentation doit être signalée et arbitrée contre ce document.*
+590
View File
@@ -0,0 +1,590 @@
# Module FINANCE — Recherche sur les sources de données et l'import
> Document de recherche pour LifeTrack (module Finance). Rédigé le 2026-08-13.
> Public : agents d'implémentation. Ce document est autoportant — **aucune recherche complémentaire n'est prévue**.
> Convention : prose en français, identifiants de code en anglais.
---
## Sommaire
1. [Vue d'ensemble et enseignements clés](#1-vue-densemble-et-enseignements-clés)
2. [Formats d'export des banques françaises (fichiers)](#2-formats-dexport-des-banques-françaises-fichiers)
3. [Export d'activité PayPal](#3-export-dactivité-paypal)
4. [Le format OFX en France (et QIF)](#4-le-format-ofx-en-france-et-qif)
5. [Agrégation PSD2 : état des lieux 2025/2026](#5-agrégation-psd2--état-des-lieux-20252026)
6. [Stratégies de déduplication](#6-stratégies-de-déduplication)
7. [Recommandations d'implémentation — v1 (import fichiers)](#7-recommandations-dimplémentation--v1-import-fichiers)
8. [Recommandations d'implémentation — v2 (synchronisation automatique)](#8-recommandations-dimplémentation--v2-synchronisation-automatique)
9. [Sources](#9-sources)
---
## 1. Vue d'ensemble et enseignements clés
### 1.1 Constats structurants
1. **Le "CSV bancaire français" n'existe pas** : chaque banque a son propre dialecte. Points communs majoritaires : séparateur `;`, virgule décimale, dates `JJ/MM/AAAA`, encodage `ISO-8859-1`/`ISO-8859-15` (Windows-1252 en pratique). Exceptions notables : BoursoBank (dates `AAAA-MM-JJ`), Revolut et N26 (séparateur `,`, point décimal, UTF-8).
2. **Beaucoup de fichiers ont un préambule** (lignes d'en-tête métier avant la ligne d'en-têtes de colonnes) : Crédit Agricole (nombre de lignes **variable**), Société Générale (1 ligne), La Banque Postale (~8 lignes), BNP (1 ligne de solde). Le mapper CSV générique doit donc savoir **sauter N lignes** et/ou **détecter la ligne d'en-tête**.
3. **Les profondeurs d'historique téléchargeable sont faibles** chez les banques traditionnelles (30 à 90 jours typiquement, 6 mois chez SG) : l'utilisateur devra importer régulièrement, d'où l'importance capitale de la **déduplication** et des **fenêtres de recouvrement** (mieux vaut réimporter large que de créer des trous).
4. **OFX est disponible mais imparfait en France** : versions SGML 1.x, encodages legacy, et surtout des `FITID` non fiables chez certaines banques (cas documenté LCL : FITID = type+date+montant ⇒ collisions). Le FITID est un bon signal de dédup, **jamais une garantie**.
5. **Bouleversement PSD2 2025** : GoCardless Bank Account Data (ex-Nordigen), la solution gratuite historique des self-hosters (utilisée par Firefly III et Actual Budget), **n'accepte plus de nouveaux comptes depuis juillet 2025** et est en cours d'extinction. La relève gratuite pour un particulier est **Enable Banking** (mode "restricted" gratuit sur ses propres comptes), désormais supportée par Firefly III et Actual Budget.
6. Firefly III et Actual Budget fournissent des modèles éprouvés de déduplication : identifiant externe prioritaire (`imported_id` / "external identifier"), puis hash de contenu, puis rapprochement flou (montant identique + date proche + libellé similaire). Nous reprenons cette hiérarchie.
### 1.2 Décision recommandée (résumé)
- **v1** : import par fichiers uniquement. Un **mapper CSV générique** (encodage, séparateur, préambule, mapping de colonnes, format de date, virgule décimale, colonnes débit/crédit vs montant signé) + **presets par banque** livrés en JSON + **parseur OFX** (lib Python `ofxparse`) + **preset PayPal**. Déduplication à 3 niveaux (voir §6.4). Saisie manuelle et règles de catégorisation.
- **v2** : connecteur **Enable Banking** (PSD2 officiel, gratuit en mode restreint pour ses propres comptes, couvre les grandes banques françaises) branché sur le **même pipeline de staging/dédup** que les fichiers. Connecteur GoCardless conservé en option pour les détenteurs de comptes historiques. `woob` documenté comme adaptateur optionnel "fragile", non prioritaire.
---
## 2. Formats d'export des banques françaises (fichiers)
### 2.0 Tableau de synthèse
| Banque | Formats | Séparateur CSV | Encodage | Format date | Décimale | Montant | Préambule | Historique |
|---|---|---|---|---|---|---|---|---|
| BoursoBank | CSV, OFX, QIF | `;` | UTF-8 | `AAAA-MM-JJ` | virgule (sauf col. solde : point !) | signé, 1 colonne | non | plusieurs années, période libre |
| Crédit Agricole | CSV, Excel, OFX, QIF (selon caisse) | `;` | ISO-8859-15 | `JJ/MM/AAAA` | virgule | 2 colonnes Débit/Crédit | oui, **variable** (fin = ligne `Date;`) | ~3090 j selon caisse |
| BNP Paribas | CSV, OFX, QIF, PDF | `;` | ISO-8859-1 | `JJ/MM/AAAA` | virgule (+ espace milliers) | signé, 1 colonne | 1 ligne (solde, HTML-échappée 2×) | ~90 j |
| Société Générale | CSV, QIF (revenu en 2020) | `;` | ISO-8859-1/15, CRLF | `JJ/MM/AAAA` | virgule | signé, 1 colonne | 1 ligne (`="compte"`;début;fin;) | ~6 mois |
| La Banque Postale | CSV, TSV, OFX | `;` | ISO-8859-15 | `JJ/MM/AAAA` | virgule | signé, 1 colonne | ~8 lignes (n° compte, soldes) | ~90 j |
| Caisse d'Épargne | CSV (OFX selon interfaces) | `;` | ISO-8859-1 | `JJ/MM/AAAA` | virgule | 2 colonnes Débit/Crédit (crédit préfixé `+`) | non (nouveau format) | limité (pas de solde dans le fichier) |
| Fortuneo | CSV, XLS, QIF, OFX | `;` | ISO-8859-1 / Windows-1252 (à détecter) | `JJ/MM/AAAA` | virgule | 2 colonnes Débit/Crédit | non | jusqu'à ~10 ans |
| Revolut | CSV, Excel, PDF | `,` | UTF-8 | `AAAA-MM-JJ HH:MM:SS` | point | signé + colonne `Fee` | non | historique complet |
| N26 | CSV | `,` | UTF-8 | `AAAA-MM-JJ` | point | signé, 1 colonne | non | historique complet |
| PayPal | CSV, TAB (QIF USD only) | `,` | UTF-8 | selon locale (`JJ/MM/AAAA` en FR) | selon locale (virgule en FR) | Gross/Fee/Net | non | 7 ans (tranches de 12 mois) |
Détails et pièges banque par banque ci-dessous. Les en-têtes cités sont **exacts** (issus de fichiers réels analysés dans des projets open-source, notamment `mincong-h/finance-toolkit`, et de documentations d'import OpenFlyers).
### 2.1 BoursoBank (ex-Boursorama Banque)
- **Accès** : espace client → historique du compte → sélection de période libre → « Exporter » ; formats **CSV, OFX, QIF** proposés. Tous les comptes sélectionnés sont regroupés dans **un seul fichier**.
- **Nom de fichier** : `export-operations-{JJ}-{MM}-{AAAA}_{hh}-{mm}-{ss}.csv` (date de génération).
- **Encodage** : UTF-8. **Séparateur** : `;`. Champs texte entre guillemets doubles.
- **En-tête exact (1re ligne)** :
```
dateOp;dateVal;label;category;categoryParent;amount;comment;accountNum;accountLabel;accountbalance
```
- **Exemple de ligne réelle** :
```
2021-08-17;2021-08-17;"Prime Parrainage";"Virements reçus";"Virements reçus";130,00;;001234;"BOURSORAMA BANQUE";226.68
```
- **Particularités / pièges** :
- Dates en **`AAAA-MM-JJ`** (seule banque française classique dans ce cas).
- `amount` utilise la **virgule** décimale, mais `accountbalance` utilise le **point** décimal dans le même fichier. Ne jamais parser les deux colonnes avec la même routine.
- La colonne `accountbalance` est un solde recalculé glissant, peu fiable : **l'ignorer** pour la comptabilité ; le solde de compte doit être saisi/rapproché séparément.
- `category`/`categoryParent` : catégorisation maison BoursoBank — utile comme **suggestion** de catégorie initiale au mapping.
- `accountNum` répété sur chaque ligne ⇒ permet de **router les lignes vers plusieurs comptes** LifeTrack depuis un fichier unique (le preset doit gérer un fichier multi-comptes).
- Le PEA n'est pas exportable.
### 2.2 Crédit Agricole
- **Accès** : espace client (par **caisse régionale** — les interfaces varient) → « Vos opérations » → « Télécharger vos opérations ». Formats selon caisse : **CSV, Excel (xls), OFX, QIF, TXT**. Historique en ligne court (souvent 30 à 90 jours).
- **Structure CSV** (modèle documenté par OpenFlyers) :
- **Encodage** : ISO-8859-15. **Séparateur** : `;`.
- **Préambule de longueur variable** (infos compte, période, solde). La fin du préambule est repérable par la ligne commençant par `Date;`.
- **En-tête** : `Date;Date valeur;Libellé;Débit Euros;Crédit Euros;`
- Dates `JJ/MM/AAAA`, virgule décimale, montants ventilés en **deux colonnes** Débit/Crédit.
- **Les libellés peuvent contenir des retours à la ligne** (champ multi-lignes entre guillemets) — le parseur CSV doit être configuré pour les champs quotés multi-lignes (le module Python `csv` le gère nativement si on ne pré-découpe pas par lignes).
- Un **pied de page** peut suivre les données (séparé par des lignes vides) — arrêter le parsing à la première ligne dont la 1re colonne n'est pas une date valide.
- **Implémentation preset** : `skip_until_header_startswith: "Date;"` + `stop_on_non_date_row: true`.
### 2.3 BNP Paribas
- **Accès** : mabanque.bnpparibas → « Virements et services » → « Téléchargement des opérations » (URL directe : `/fr/secure/virements-services/telechargement-des-operations` ; la page a été retirée de la navigation en juillet 2023 mais restait accessible en direct). Formats : **PDF, CSV, OFX, QIF**, fenêtre ~90 jours.
- **Nom de fichier** : type `E{digits}.csv` se terminant par les 4 derniers chiffres du compte (ex. `E0790170.csv`) ; regex utilisable : `E\d+{last4}\.csv`.
- **Structure CSV** (fichier réel) :
- **Encodage** : ISO-8859-1. **Séparateur** : `;`.
- **1re ligne = métadonnées de solde**, PAS un en-tête de colonnes :
```
"Cr&eacute;dit immobilier";"Cr&amp;eacute;dit immobilier";****1234;18/03/2022;;-123 456,78
```
Soit : libellé compte ; libellé compte (échappé HTML **deux fois** — il faut appliquer `html.unescape()` **2×**) ; n° compte masqué ; date d'export ; (vide) ; **solde** avec espace de milliers et virgule décimale.
- **Lignes suivantes = opérations**, sans ligne d'en-tête :
```
05/01/2022;;; AMORTISSEMENT PRET 1234;70,93
```
Colonnes : `date; (vide); (vide); libellé; montant_signé`. Date `JJ/MM/AAAA`, virgule décimale, espaces de milliers possibles.
- **Implémentation preset** : `header_rows: 1` (ligne solde à parser à part pour proposer un rapprochement de solde), `columns: [date, skip, skip, label, amount]`, `has_column_header: false`.
### 2.4 Société Générale
- **Accès** : espace client → « Gestion et suivi » / « Relevés et documents » → export **CSV** (~6 mois d'historique). Le **QIF** avait disparu à la refonte du site (2019) puis est **revenu en février 2020**.
- **Structure CSV** (fichier réel analysé par enodev.fr) :
- **Encodage** : ISO-8859-1 (ou -15), fins de ligne **CRLF**. **Séparateur** : `;`.
- **Ligne 1 (préambule)** : `="0201900016400270";17/05/2019;16/11/2019;` — n° de compte en notation Excel `="…"` (pour préserver les zéros de tête), puis début et fin de période.
- **Ligne 2 (en-tête)** : `date_comptabilisation;libellé_complet_operation;montant_operation;devise;`
- **Ligne de données** : `15/11/2019;CARTE X7527 15/11 METRO ;-14,90;EUR;`
- Date `JJ/MM/AAAA`, **montant signé** unique, virgule décimale, devise explicite, point-virgule terminal (colonne vide finale).
- Les libellés sont **paddés d'espaces** et peuvent s'étaler sur plusieurs lignes pour les virements/prélèvements ⇒ `strip()` + gestion des champs multi-lignes.
- **Implémentation preset** : `header_rows: 1` puis ligne d'en-têtes ; extraire le n° de compte de la ligne 1 via regex `="(\d+)"`.
### 2.5 La Banque Postale
- **Accès** : espace client → menu « OPÉRATIONS » → « Téléchargement d'opérations » → choix du compte → « Format CSV (compatible Excel) » (aussi **TSV** et **OFX** selon le type de compte, via le bouton « Télécharger le détail »). **Limite : ~90 jours** d'historique.
- **Structure CSV** (modèle documenté par OpenFlyers) :
- **Encodage** : ISO-8859-15. **Séparateur** : `;`. Pas de pied de page.
- **Préambule (~8 lignes)**, exploitables pour le rapprochement de solde :
```
Numéro Compte ;[numéro]
Type ;COMPTE
Compte tenu en ;euros
Date ;[date]
Solde (EUROS) ;[solde]
Solde (FRANCS) ;[solde]
```
- **En-tête de colonnes** : `Date;Libellé;Montant(EUROS);Montant(FRANCS)`
- Date `JJ/MM/AAAA`, **montant signé** (négatif = débit), virgule décimale. La colonne `Montant(FRANCS)` est un vestige à ignorer.
- Attention : l'export **TSV perd la date de valeur** et est découpé mois par mois — préférer CSV.
- **Implémentation preset** : `skip_until_header_startswith: "Date;"` (robuste face aux variations du préambule), colonne FRANCS ignorée.
### 2.6 Caisse d'Épargne (groupe BPCE)
- **Accès** : espace client → sur le compte, « Gérer » → « Télécharger les opérations » (page d'aide officielle : `aide.caisse-epargne.fr/contents/comment-exporter-mes-operations`). Le fichier **ne contient pas le solde**.
- **Noms de fichier observés** : `{DDMMYYYY}_{numéro}.csv` (ancien) et `{numéro}_{DDMMYYYY}_{DDMMYYYY}.csv` (récent, période début/fin). Regex preset : `\d*{account}_\d{8}_\d{8}\.csv` et `\d{8}_{account}\.csv`.
- **Structure CSV « nouveau format » (2024+, fichier réel)** :
- **Encodage** : ISO-8859-1. **Séparateur** : `;`. Pas de préambule.
- **En-tête exact** :
```
Date de comptabilisation;Libelle simplifie;Libelle operation;Reference;Informations complementaires;Type operation;Categorie;Sous categorie;Debit;Credit;Date operation;Date de valeur;Pointage operation
```
- **Exemple** :
```
15/11/2024;SUPERMARCHE;CB SUPERMARCHE CENTRAL FACT 141124;;;Carte bancaire;Alimentation;Hyper/supermarche;-45,50;;14/11/2024;15/11/2024;0
10/11/2024;EMPLOYEUR SA;VIR INST Employeur SA;REF123456;Salaire Novembre-;Virement recu;Revenus;Salaires;;+3500,00;09/11/2024;09/11/2024;0
```
- Dates `JJ/MM/AAAA` (3 colonnes de dates : comptabilisation / opération / valeur), virgule décimale, **Débit en négatif** dans sa colonne, **Crédit préfixé `+`** — le parseur de montants doit accepter `+` et `-`.
- `Categorie`/`Sous categorie` : suggestions de catégorisation. `Libelle simplifie` = nom de marchand nettoyé, excellent pour l'affichage et les règles.
- Banque Populaire (même groupe BPCE) a des exports proches (« Documents → Vos écritures et opérations → CSV ») — le preset CE servira de base si besoin.
### 2.7 Fortuneo
- **Accès** : espace client → historique du compte → export. Formats annoncés : **CSV, Excel, QIF, OFX**, avec un historique allant jusqu'à **10 ans** (le plus généreux des banques FR).
- **Nom de fichier** : `HistoriqueOperations_{compte}_du_JJ_MM_AAAA_au_JJ_MM_AAAA.csv`.
- **Structure CSV** (fichier réel) :
- **Séparateur** : `;`. **Encodage** : historiquement ISO-8859-1/Windows-1252 (des accents cassés sont observés si lu en UTF-8) ; des exports récents semblent être en UTF-8 ⇒ **toujours passer par la détection d'encodage** (voir §7.3).
- **En-tête exact** (noter la casse et le `;` final) :
```
Date opération;Date valeur;libellé;Débit;Crédit;
```
- **Exemple** : `13/12/2019;13/12/2019;CARTE 12/12 FNAC METZ;-6,4;`
- Dates `JJ/MM/AAAA`, virgule décimale, **Débit déjà signé négatif**, montants parfois sans zéro final (`-6,4`), espaces de milliers possibles.
- **Pièges** : les opérations **carte à débit différé** sont isolées puis intégrées à la liste le dernier jour du mois (risque de « trou » puis d'apparition tardive ⇒ importance de la fenêtre de recouvrement) ; les opérations Bourse ne sont pas distinguées des crédits ordinaires.
### 2.8 Revolut
- **Accès** : app/web → Relevés (« Statements ») → export **CSV / Excel / PDF** par compte-devise et par période. Pas d'OFX/QIF. Historique complet disponible.
- **Nom de fichier** : `account-statement_{AAAA-MM-JJ}_{AAAA-MM-JJ}_{...}_{id}.csv`.
- **Structure CSV** (fichier réel) :
- **Séparateur** : `,`. **Encodage** : UTF-8. **Point décimal**.
- **En-tête exact** :
```
Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance
```
- **Exemple** : `TOPUP,Current,2024-01-05 14:00:40,2024-01-05 14:00:41,Payment from M Huang Mincong,10.00,0.00,USD,COMPLETED,74.43`
- Dates `AAAA-MM-JJ HH:MM:SS` (heure locale du compte).
- **Règles d'import** :
- **N'importer que `State == COMPLETED`** (les états `PENDING`/`REVERTED` changent ou disparaissent — source classique de doublons).
- `Amount` est **hors frais** ; l'impact réel sur le solde = `Amount - Fee` (Fee est positif). Deux stratégies : (a) créer une transaction unique de montant net, en notant le frais dans un champ `metadata` ; (b) créer deux transactions (opération + frais). Recommandé v1 : **montant net + note**, plus simple pour les budgets.
- Un compte Revolut = plusieurs devises ⇒ un export par devise ; modéliser un `account` LifeTrack par devise, ou stocker `currency` par transaction.
- `Type` utiles : `TOPUP`, `CARD_PAYMENT`, `TRANSFER`, `EXCHANGE`, `ATM`, `FEE` — mappables vers des catégories par défaut.
### 2.9 N26
- **Accès** : application web (app.n26.com) → téléchargement des activités (« Download activities ») par période. **CSV uniquement** (pas d'OFX/QIF).
- **Structure CSV actuelle (format 2023+)** :
- **Séparateur** : `,`. **Encodage** : UTF-8. **Point décimal**. Dates `AAAA-MM-JJ`. Champs quotés (sauf `Type`).
- **En-tête exact** :
```
"Booking Date","Value Date","Partner Name","Partner Iban",Type,"Payment Reference","Account Name","Amount (EUR)","Original Amount","Original Currency","Exchange Rate"
```
- `Partner Iban` = IBAN de la contrepartie (précieux pour les règles de catégorisation et la détection de virements internes). `Original Amount/Currency/Exchange Rate` renseignés pour les paiements hors EUR.
- **Ancien format (avant ~2023)**, à supporter en option dans le preset (des utilisateurs ont des archives) :
```
"Date","Payee","Account number","Transaction type","Payment reference","Amount (EUR)","Amount (Foreign Currency)","Type Foreign Currency","Exchange Rate"
```
- Le preset N26 doit **détecter la variante par la ligne d'en-tête**.
---
## 3. Export d'activité PayPal
### 3.1 Où et quoi
- **Accès** : paypal.com → Activité → « Télécharger » / Relevés → « Activité personnalisée » ; ou Rapports (comptes business) → « Activity Download ».
- **Formats** : **CSV**, **TAB**, PDF ; QIF (USD uniquement) et IIF (US uniquement) — ignorer QIF/IIF.
- **Limites** : historique **7 ans**, période max **12 mois par rapport**, **50 000 lignes max** par fichier (sinon ZIP multi-fichiers). Préréglages : « depuis le dernier téléchargement », mois écoulé, 3 mois, 6 mois…
- **Encodage** : UTF-8. **Séparateur** : `,` avec champs quotés.
- **Locale FR — piège majeur** : dates en `JJ/MM/AAAA` et **virgule décimale à l'intérieur des champs quotés** (ex. `"1 234,56"`). Un compte configuré en anglais exporte en `MM/DD/YYYY` avec point décimal. Le preset PayPal doit donc proposer le choix de locale (défaut FR).
### 3.2 Colonnes
Le rapport « Activity Download » est **personnalisable** (87 champs possibles). Champs **obligatoires** (toujours présents) :
```
Date, Time, TimeZone, Name, Type, Status, Currency, Gross, Fee, Net,
From Email Address, To Email Address, Transaction ID, Reference Txn ID,
Receipt ID, Balance Impact
```
Champs cochés par défaut (sélection) : `Balance`, `Subject`, `Note`, `Invoice Number`, `Country Code`, adresses, etc.
Sur les **comptes personnels**, l'export simplifié peut ne contenir qu'un sous-ensemble du type : `Date, Time, TimeZone, Name, Type, Status, Currency, Amount, Receipt ID, Balance` (une seule colonne `Amount` au lieu de Gross/Fee/Net). Le preset doit accepter **les deux variantes** (détection par en-tête).
### 3.3 Sémantique des montants et devises
- `Gross` = montant brut signé ; `Fee` = frais (négatif) ; **`Net = Gross + Fee`**.
- `Balance Impact` ∈ {`Credit`, `Debit`, `Memo`} : les lignes `Memo` (autorisations, paiements en attente, lignes informatives) **n'affectent pas le solde ⇒ les exclure de l'import**.
- `Status` : n'importer que `Completed` (exclure `Pending`, `Denied`, `Reversed`…).
- **Conversions de devises** : un achat en devise génère 23 lignes liées (`Type` contenant "Currency Conversion" / « Conversion de devise » : une ligne de débit dans la devise d'origine, une ligne de crédit en EUR, liées par `Reference Txn ID`). Stratégie v1 recommandée : **importer uniquement les lignes dont `Currency` == devise du compte LifeTrack (EUR) et `Balance Impact` != `Memo`**, ce qui capture l'effet net en euros sans doublonner.
- `Transaction ID` : identifiant alphanumérique 17 caractères, **unique et stable** ⇒ clé de déduplication idéale (`external_id`).
- `Reference Txn ID` : lie remboursements/conversions à la transaction d'origine — à stocker en métadonnée.
---
## 4. Le format OFX en France (et QIF)
### 4.1 Ce qu'on trouve réellement
- Les banques françaises qui proposent OFX (BoursoBank, BNP, La Banque Postale, Crédit Agricole selon caisse, Fortuneo…) livrent quasi toujours de l'**OFX 1.x SGML** (en-tête `OFXHEADER:100`, `DATA:OFXSGML`, `VERSION:102`), **pas** de l'OFX 2.x XML. Encodage souvent déclaré `ENCODING:USASCII`/`CHARSET:1252` mais réellement Windows-1252/Latin-1.
- Structure utile par transaction (`<STMTTRN>`) : `TRNTYPE` (DEBIT/CREDIT/XFER/…), `DTPOSTED` (`AAAAMMJJ`), `TRNAMT` (**point décimal**, signé), `FITID`, `NAME`, `MEMO` éventuel. Le bloc `<BANKACCTFROM>` donne banque/guichet/compte — parfait pour router vers le bon compte.
- **Avantages vs CSV** : pas d'ambiguïté de date/décimale, identifiant `FITID`, solde de fin (`<LEDGERBAL>`), n° de compte inclus.
### 4.2 Fiabilité du FITID — mise en garde
La spec OFX exige que le FITID identifie de façon unique une transaction **dans le périmètre d'un compte** et reste **stable entre téléchargements** (« FITIDs must be unique within the scope of […] an account » ; unicité inter-banques non garantie ⇒ clé = banque+compte+FITID). En pratique :
- **LCL (cas documenté par Akretion)** : FITID fabriqué comme `code_type + date JJMMAA + montant en centimes` (ex. `948 200423 -1275`) ⇒ **deux paiements CB du même montant le même jour = même FITID**. Violation flagrante, présente depuis au moins 2016.
- D'autres établissements (cas US documentés : Discover…) **régénèrent des FITID différents à chaque téléchargement** pour la même transaction.
**Conséquence pour LifeTrack** : traiter le FITID comme `external_id` de dédup **prioritaire mais non exclusif** — toujours doubler d'un hash de contenu, et ne jamais planter sur un FITID dupliqué à l'intérieur d'un même fichier (suffixer par un compteur d'occurrence, cf. §6.4).
### 4.3 QIF
Format texte Quicken sans identifiants, dates ambiguës (`D` au format local), pas de devise, pas de n° de compte. Fortuneo, SG, BNP, CA le proposent encore. **Ne pas l'implémenter en v1** (CSV + OFX couvrent tout) ; à garder en idée v3 si un utilisateur n'a que ça.
### 4.4 Bibliothèques Python
- **`ofxparse`** : tolérant, gère l'OFX 1.x SGML sale des banques (via BeautifulSoup/sgmllib). Maintenance faible mais c'est le standard de fait pour ce besoin. **Recommandé v1**, avec pré-traitement : détection/normalisation d'encodage avant parsing.
- **`ofxtools`** : plus strict/complet (OFX 2.x, typage), moins indulgent avec les fichiers non conformes français. Alternative si `ofxparse` pose problème.
---
## 5. Agrégation PSD2 : état des lieux 2025/2026
### 5.1 GoCardless Bank Account Data (ex-Nordigen) — en extinction
- Historiquement **LA** solution gratuite : jusqu'à **50 connexions bancaires/mois** gratuites, couverture de ~2500 banques UE dont toutes les grandes banques françaises, consentement PSD2 de 90 jours (180 pour certaines banques), jusqu'à 24 mois d'historique selon banque.
- **Limite de taux introduite en 2024 : ~4 appels de synchronisation par jour et par compte** (les importeurs comme Firefly III data importer ≥ 1.5.6 la gèrent).
- API simple : `secret_id`/`secret_key` → token ; `GET /institutions?country=fr` ; création d'une « requisition » (lien d'autorisation redirigeant vers la banque) ; puis `GET /accounts/{id}/transactions` (JSON, montants `transactionAmount.amount` + `currency`, `internalTransactionId`/`transactionId` pour la dédup).
- **⚠️ Depuis juillet 2025 : plus aucune inscription nouvelle** (« GoCardless has stopped accepting new Bank Account Data accounts », confirmé par la doc Actual Budget) ; le produit est en cours d'abandon. Les comptes existants continuent de fonctionner, sans garantie de durée.
- **Conclusion** : notre utilisateur ne pourra probablement **pas** créer de compte ⇒ GoCardless ne peut plus être le connecteur v2 par défaut ; il reste pertinent comme **connecteur optionnel** pour détenteurs de comptes historiques.
### 5.2 Enable Banking — la relève recommandée
- Agrégateur finlandais s'appuyant sur les **API PSD2 officielles** (~2500 banques, 29 pays). Utilisé comme alternative par les communautés Firefly III (tutoriel officiel `docs.firefly-iii.org/tutorials/data-importer/eb/`) et Actual Budget (guide de setup dédié).
- **Mode « restricted » gratuit, adapté à un particulier** : on enregistre une application de **production** dans le Control Panel (enablebanking.com), puis « **Activate by linking accounts** » : on autorise ses propres comptes via le portail Enable Banking + la page d'autorisation de la banque. L'application ne peut ensuite récupérer **que les comptes préalablement liés** (whitelist), tant qu'aucun contrat commercial n'est signé. C'est exactement le périmètre « mes propres comptes ».
- **Authentification API** : l'application possède une **clé privée RSA** ; chaque requête est signée par un **JWT RS256** (kid = application_id). Endpoints principaux : `POST /auth` (démarrage d'autorisation, URL de redirection), `POST /sessions` (échange du code), `GET /sessions/{id}`, `GET /accounts/{uid}/transactions` (pagination par `continuation_key`), `GET /accounts/{uid}/balances`.
- **Couverture France confirmée** (doc `enablebanking.com/docs/markets/fr/`) : BNP Paribas, **Crédit Agricole** (choix de la **caisse régionale** ; SCA via l'app « Ma Banque »), Société Générale, La Banque Postale, Crédit Mutuel, CIC, LCL, Banque Populaire, **Caisse d'Épargne**, etc. Les flux d'authentification français sont de type **redirect** avec SCA sur l'app mobile de la banque. BoursoBank/Fortuneo figurent dans le réseau PSD2 français (STET) ; **Revolut et N26** exposent aussi des API PSD2 européennes — vérifier leur présence exacte dans la liste d'ASPSP du Control Panel au moment de l'implémentation (non listés sur la page marché FR).
- **Contraintes PSD2 invariables** : consentement à renouveler (90 jours réglementaires, jusqu'à 180 selon banque), historique initial limité par la banque (souvent 90 jours à 24 mois au premier accès), chaque session doit être autorisée via API même en mode restreint.
### 5.3 Autres options commerciales (non retenues)
- **Powens** (ex-Budget Insight, adossé Crédit Mutuel Arkéa) : excellente couverture FR (y compris épargne/assurance-vie), mais **B2B, tarification sur contrat**, pas d'offre particulier.
- **Bridge** (ex-Bankin' B2B, adossé BPCE) : idem, B2B uniquement.
- **Tink** (Visa) : B2B, plus de free tier significatif pour un usage personnel.
- **SimpleFIN** : populaire chez Actual Budget mais **couvre les banques nord-américaines** — hors sujet pour la France.
### 5.4 woob (ex-weboob) — scraping open-source
- Framework Python AGPL de scraping bancaire (modules `boursorama`, `creditagricole`, `fortuneo`, etc.), moteur historique de **Kresus** (gestionnaire de finances self-hosted français). Projet actif (GitLab `woob/woob`).
- **Fragilité structurelle** : les modules cassent à chaque refonte des sites (ex. documenté : synchronisation BoursoBank cassée à l'automne 2025, `AttributeError` dans le module boursorama). Nécessite de stocker les **identifiants bancaires en clair côté serveur**, et le scraping peut déclencher des blocages / est contraire aux CGU de certaines banques.
- **Position LifeTrack** : ne pas en faire une dépendance cœur. Au mieux, un adaptateur optionnel v3 (« woob bridge » qui exporte du JSON/CSV consommé par notre pipeline d'import).
### 5.5 Ce que font Firefly III et Actual Budget (référence)
| App | Import fichiers | Sync bancaire |
|---|---|---|
| Firefly III (+ Data Importer) | CSV (mapper générique + configs communautaires JSON par banque, dépôt `firefly-iii/import-configurations` : profils `fr/boursorama`, `fr/fortuneo`…), camt.053 | GoCardless (legacy), **Enable Banking** (nouveau), SimpleFIN |
| Actual Budget | CSV/OFX/QFX/QIF/CAMT | GoCardless (legacy), SimpleFIN (US), **Enable Banking**, Pluggy.ai (Brésil) |
Enseignement : les deux références self-hosted ont pivoté **GoCardless → Enable Banking** pour l'Europe ; leur modèle « mapper générique + profils par banque en JSON versionnés » est exactement l'architecture retenue pour LifeTrack v1.
---
## 6. Stratégies de déduplication
Le besoin : l'utilisateur réimporte régulièrement des fichiers **qui se chevauchent** (historique court chez les banques FR), et pourra un jour cumuler fichier + sync API sur le même compte. Il faut être **idempotent** sans perdre de vraies transactions identiques (deux cafés à 2,50 € le même jour chez le même commerçant sont légitimes).
### 6.1 Ce que fait l'état de l'art
- **Firefly III** : deux mécanismes — (1) « content-based » : hash SHA-256 du JSON complet de la transaction soumise, comparé aux hashes existants (fragile : le hash change si la banque change la casse ou si le mapping change) ; (2) « identifier-based » : une colonne mappée sur `external_id`/`internal_reference` est recherchée avant import — « a very reliable way to detect duplicates ».
- **Actual Budget** : champ **`imported_id`** (FITID pour OFX, id GoCardless pour la sync) — « transactions with the same imported_id will never be added more than once » ; sinon **rapprochement flou** : même montant + date proche (fenêtre de quelques jours) + payee similaire ⇒ fusion proposée. Bug historique corrigé en 2024 : le fuzzy match ne doit **pas** fusionner deux transactions portant des `imported_id` différents (sauf quirk GoCardless) — règle à reprendre telle quelle.
### 6.2 Clés candidates
1. **`external_id` fourni par la source** : OFX `FITID` (voir caveats §4.2), PayPal `Transaction ID` (fiable), GoCardless `internalTransactionId` (fiable), Enable Banking `entry_reference` (fiabilité variable selon banque). Unicité à imposer **par (account_id, source_kind)**, jamais globalement.
2. **Hash de contenu** : les CSV français n'ont **aucun identifiant** ⇒ hash déterministe sur les champs stables : compte + date comptable + montant + libellé normalisé + devise.
3. **Rapprochement flou** (aide à la décision, jamais automatique en suppression) : même compte, même montant, date à ±3 jours, similarité de libellé (trigrammes `pg_trgm`) — utile parce que les banques FR **changent le libellé et la date** entre l'opération « en cours » et l'opération comptabilisée.
### 6.3 Pièges spécifiques observés
- **Libellés instables** : SG padde d'espaces ; CB « en cours » devient « CARTE 12/12 FNAC METZ » une fois comptabilisée ; Fortuneo intègre le débit différé en fin de mois. ⇒ normalisation agressive du libellé avant hash (cf. ci-dessous) et fenêtre de recouvrement.
- **Doublons légitimes intra-journée** : gérés par un **compteur d'occurrence** intégré au hash — technique éprouvée (les import-id YNAB/Actual sont suffixés d'un index d'occurrence). Dans un même fichier, la n-ième ligne strictement identique reçoit `occurrence = n`.
- **FITID dupliqué dans un même fichier OFX** (cas LCL) : appliquer le même compteur d'occurrence au FITID.
- **Balance/solde glissant** (Boursorama `accountbalance`) : ne jamais inclure de colonne de solde dans le hash.
- **Catégories fournies par la banque** (Bourso, CE) : ne pas les inclure dans le hash (elles changent au gré des algos de la banque).
### 6.4 Algorithme retenu pour LifeTrack
Pipeline d'import (fichier ou API) → table de **staging** → dédup → commit :
```text
for each parsed row:
1. Normalize: booking_date (ISO), amount (Decimal, 2 dec), label_norm, currency.
2. If source provides an external id:
ext_key = (account_id, source_kind, external_id [+ ":" + occurrence])
if exists in transactions.external_key -> mark DUPLICATE (skip)
3. Compute content hash:
basis = f"{account_id}|{booking_date}|{amount:+.2f}|{currency}|{label_norm}|{occurrence}"
dedup_hash = sha256(basis)
occurrence = index of this exact basis within the CURRENT import file (0,1,2…)
if dedup_hash exists in transactions -> mark DUPLICATE (skip)
4. Fuzzy pass (only for rows that survived 2 and 3):
candidates = same account, same amount, |date diff| <= 3 days,
similarity(label_norm) >= 0.5 (pg_trgm),
AND (candidate.external_id IS NULL OR row.external_id IS NULL)
if candidates -> mark NEEDS_REVIEW (user confirms merge/import in preview UI)
5. Else -> mark NEW
commit: user validates the preview (counts NEW / DUPLICATE / NEEDS_REVIEW), then batch insert.
```
Normalisation de libellé (`label_norm`) :
```python
import re, unicodedata
def normalize_label(raw: str) -> str:
s = unicodedata.normalize("NFKD", raw)
s = "".join(c for c in s if not unicodedata.combining(c)) # strip accents
s = s.upper()
s = re.sub(r"\s+", " ", s).strip() # collapse whitespace/newlines
return s
```
Parsing des montants français (couvre tous les cas observés : `-6,4`, `+3500,00`, `-123 456,78`, `1 234,56`, `226.68`) :
```python
from decimal import Decimal
def parse_amount(raw: str) -> Decimal:
s = raw.strip().replace(" ", "").replace("", "").replace(" ", "")
if "," in s:
s = s.replace(".", "").replace(",", ".") # French style
return Decimal(s) # accepts leading + or -
```
**Fenêtre de recouvrement** : encourager l'utilisateur (UI) à toujours exporter avec chevauchement (ex. « depuis 7 jours avant le dernier import ») ; côté serveur, la dédup rend le chevauchement inoffensif. Stocker par compte `last_imported_max_date` pour afficher « dernière opération connue : … » et suggérer la période d'export.
**Traçabilité** : chaque ligne insérée référence un `import_batch` (fichier source, profil utilisé, horodatage, checksum du fichier). Un batch est **annulable en bloc** (undo), et un même fichier (même checksum SHA-256) déjà importé est refusé d'emblée avec message clair.
---
## 7. Recommandations d'implémentation — v1 (import fichiers)
### 7.1 Périmètre v1
1. **Mapper CSV générique** avec presets par banque (BoursoBank, Crédit Agricole, BNP, SG, La Banque Postale, Caisse d'Épargne, Fortuneo, Revolut, N26, PayPal).
2. **Parseur OFX** (`ofxparse`) — couvre BoursoBank, BNP, LBP, CA, Fortuneo pour les utilisateurs qui préfèrent l'OFX.
3. **Preset PayPal** (variante business Gross/Fee/Net + variante perso Amount).
4. Saisie manuelle + import batch annulable + moteur de règles de catégorisation.
5. Pas de QIF, pas de PDF, pas de sync API en v1.
### 7.2 Modèle de données (SQLAlchemy — esquisse)
```python
class BankAccount(Base): # finance account, multi-user ready
id: UUID; user_id: UUID
name: str; kind: str # checking|savings|card|paypal|cash
currency: str = "EUR"
iban_last4: str | None
last_imported_max_date: date | None
class ImportProfile(Base): # a saved CSV mapping (preset or user-defined)
id: UUID; user_id: UUID | None # None => built-in preset
slug: str # "boursobank", "credit-agricole", ...
config: JSONB # see 7.4
class ImportBatch(Base):
id: UUID; user_id: UUID; account_id: UUID | None
profile_slug: str | None; source_kind: str # csv|ofx|paypal|api-...
filename: str; file_sha256: str # reject identical re-upload
created_at: datetime
stats: JSONB # {new, duplicates, review}
status: str # pending|committed|rolled_back
class Transaction(Base):
id: UUID; account_id: UUID
booking_date: date; value_date: date | None
amount: Numeric(14, 2); currency: str
label_raw: str; label_norm: str
counterparty_iban: str | None
category_id: UUID | None
source_kind: str # csv|ofx|paypal|api-enablebanking|manual
external_id: str | None # FITID / PayPal Transaction ID / API id
occurrence: int = 0
dedup_hash: str # sha256 hex, UNIQUE per account
import_batch_id: UUID | None
metadata: JSONB # bank category, fee, reference_txn_id, state...
__table_args__ = (
UniqueConstraint("account_id", "dedup_hash"),
Index(..., "account_id", "source_kind", "external_id", unique=True,
postgresql_where=text("external_id IS NOT NULL")),
)
```
Activer l'extension **`pg_trgm`** pour le fuzzy match (`similarity(label_norm, :candidate)`).
### 7.3 Chaîne de lecture des fichiers
1. **Encodage** : lire les premiers Ko ; si BOM UTF-8 ⇒ `utf-8-sig` ; sinon tenter `utf-8` strict ; en cas d'échec, `charset-normalizer` avec repli forcé `cp1252` (couvre ISO-8859-1/15 pour nos banques). Ne jamais faire confiance à l'extension.
2. **Séparateur** : `csv.Sniffer` sur un échantillon, restreint à `;`, `,`, `\t` ; heuristique de départage : compter les occurrences hors guillemets sur les 5 premières lignes.
3. **Préambule** : trois stratégies configurables par profil : `header_rows: N` (fixe) ; `skip_until_header_startswith: "Date;"` (CA, LBP) ; `has_column_header: false` + mapping positionnel (BNP). Toujours afficher un **aperçu brut** des 20 premières lignes dans l'UI pour que l'utilisateur ajuste.
4. **Champs multi-lignes** : parser avec le module `csv` sur le flux complet (jamais de `split("\n")` préalable) — requis pour CA et SG.
5. **Fin de données** : option `stop_on_non_date_row` (CA : pied de page après lignes vides).
6. **Dates** : essai ordonné des formats candidats du profil (`%d/%m/%Y`, `%Y-%m-%d`, `%Y-%m-%d %H:%M:%S`, `%d.%m.%Y`) ; en mode générique, auto-détection sur l'échantillon avec désambiguïsation JJ/MM par la présence de valeurs > 12.
7. **Montants** : `parse_amount()` de §6.4 ; modes `signed_column`, `debit_credit_columns` (CA, CE, Fortuneo), `invert_sign` (option).
### 7.4 Schéma JSON d'un profil d'import (`ImportProfile.config`)
```json
{
"file": {
"encoding": "auto",
"delimiter": ";",
"quotechar": "\"",
"header_rows": 0,
"skip_until_header_startswith": null,
"has_column_header": true,
"stop_on_non_date_row": false,
"filename_regex": "export-operations-.*\\.csv"
},
"columns": {
"booking_date": "dateOp",
"value_date": "dateVal",
"label": "label",
"amount": "amount",
"debit": null,
"credit": null,
"currency": null,
"external_id": null,
"account_hint": "accountNum",
"bank_category": ["categoryParent", "category"],
"counterparty_iban": null
},
"parsing": {
"date_formats": ["%Y-%m-%d"],
"decimal_comma": true,
"invert_sign": false,
"row_filters": [{"column": "State", "op": "equals", "value": "COMPLETED"}]
},
"dedup": {"external_id_is_reliable": false}
}
```
Les presets sont livrés dans le repo (`api/app/finance/presets/*.json`), versionnés, et **clonables** par l'utilisateur pour ajustement (les banques changent leurs formats sans préavis — c'est certain à moyen terme). La détection automatique du preset combine `filename_regex` et **signature d'en-tête** (liste de noms de colonnes attendus, comparaison insensible à la casse/accents).
### 7.5 Contenu initial des presets (récapitulatif opérationnel)
| Preset | file | columns (essentiel) |
|---|---|---|
| `boursobank` | `;`, UTF-8, header ligne 1 | `dateOp`→booking, `dateVal`→value, `label`, `amount` (virgule), `accountNum`→routage multi-comptes, `category*`→suggestion ; ignorer `accountbalance` |
| `credit-agricole` | `;`, cp1252, `skip_until_header_startswith: "Date;"`, stop_on_non_date_row | `Date`, `Date valeur`, `Libellé` (multi-lignes), `Débit Euros`/`Crédit Euros` |
| `bnp-paribas` | `;`, latin-1, `header_rows: 1`, `has_column_header: false` | positions : 0=date, 3=label, 4=amount ; ligne 1 = solde (double `html.unescape`) |
| `societe-generale` | `;`, latin-1, `header_rows: 1` | `date_comptabilisation`, `libellé_complet_operation` (trim), `montant_operation`, `devise` |
| `banque-postale` | `;`, latin-9, `skip_until_header_startswith: "Date;"` | `Date`, `Libellé`, `Montant(EUROS)` ; ignorer `Montant(FRANCS)` ; préambule → solde affichable |
| `caisse-epargne` | `;`, latin-1, header ligne 1 | `Date operation`→booking, `Date de valeur`→value, `Libelle operation`→label (+`Libelle simplifie` en metadata), `Debit`/`Credit` (accepter `+`), `Categorie`/`Sous categorie`→suggestion |
| `fortuneo` | `;`, encodage **auto**, header ligne 1 (`;` final ⇒ colonne fantôme à ignorer) | `Date opération`, `Date valeur`, `libellé`, `Débit`/`Crédit` |
| `revolut` | `,`, UTF-8, point décimal | `Completed Date`→booking, `Description`, `Amount`+`Fee`→net, `Currency`, filtre `State == COMPLETED` |
| `n26` | `,`, UTF-8, point décimal, 2 variantes d'en-tête | `Booking Date`/`Value Date`, `Partner Name`+`Payment Reference`→label, `Partner Iban`, `Amount (EUR)` |
| `paypal` | `,`, UTF-8, locale FR (date `JJ/MM/AAAA`, virgule) | `Date`+`Time`, `Name`+`Type`→label, `Net` (ou `Amount`), `Currency`, `Transaction ID`→external_id (fiable), filtres `Status == Completed` et `Balance Impact != Memo` et `Currency == EUR` |
### 7.6 Import OFX
- Endpoint d'upload commun ; si le contenu commence par `OFXHEADER` ou `<?xml` + `<OFX>`, router vers le parseur OFX.
- Pré-traitement : décoder selon §7.3 puis passer à `ofxparse.OfxParser.parse()`.
- Mapper : `stmt.account.account_id`/`routing_number` → proposition de compte LifeTrack ; par transaction : `date`→booking_date, `amount` (Decimal, point), `payee`+`memo`→label, `id` (FITID)→external_id avec `external_id_is_reliable: false` (⇒ le hash de contenu reste co-vérifié) et compteur d'occurrence en cas de FITID dupliqué dans le fichier (cas LCL).
- Exploiter `<LEDGERBAL>` pour proposer un rapprochement de solde après import.
### 7.7 UI d'import (rappel UX, libellés FR)
Assistant en 4 étapes : **1. Fichier** (drag & drop, détection preset, choix du compte) → **2. Réglages** (aperçu brut, mapping colonnes éditable, formats) → **3. Prévisualisation** (tableau : Nouvelles / Doublons ignorés / À vérifier, avec diff pour les fuzzy matches) → **4. Confirmation** (stats du batch, bouton « Annuler cet import » disponible ensuite dans l'historique des imports).
---
## 8. Recommandations d'implémentation — v2 (synchronisation automatique)
### 8.1 Choix du fournisseur : Enable Banking (et pourquoi pas GoCardless)
- **GoCardless BAD est fermé aux nouveaux comptes depuis juillet 2025** (§5.1) ⇒ inutilisable pour un nouvel utilisateur. Garder un connecteur optionnel « legacy » n'est justifié que si peu coûteux (l'API est simple) ; le marquer *deprecated* dès le départ.
- **Enable Banking** coche toutes les cases pour LifeTrack : API PSD2 officielles, **gratuit en mode restreint sur ses propres comptes** (whitelist via « Activate by linking accounts »), couverture des banques du user (CA par caisse régionale, BNP, SG, LBP, CE…), déjà éprouvé par Firefly III et Actual Budget. Prérequis utilisateur : créer un compte enablebanking.com, une application de production, télécharger la **clé privée**, lier ses comptes dans le portail.
### 8.2 Architecture du connecteur (framework « connector »)
```python
class FinanceConnector(Protocol): # in the shared connector framework
slug: str
async def list_accounts(self) -> list[RemoteAccount]: ...
async def fetch_transactions(self, remote_account_id: str,
date_from: date) -> AsyncIterator[RawTransaction]: ...
async def fetch_balance(self, remote_account_id: str) -> Balance: ...
async def authorization_status(self) -> AuthStatus # consent expiry etc.
```
- **Config stockée chiffrée** (table `connector_credentials`) : `application_id`, clé privée PEM (chiffrée au repos avec la clé applicative), mapping `remote_account_uid → bank_account_id`.
- **Auth Enable Banking** : générer un JWT RS256 par requête (`iss`/`aud` selon doc, `kid = application_id`, exp courte). Flux de consentement : `POST /auth` → URL de redirection bancaire (ouvrir dans le navigateur, callback vers l'UI LifeTrack) → `POST /sessions` → stocker `session_id` + échéance de consentement.
- **Sync** : job planifié (APScheduler dans l'API) 12×/jour + bouton « Synchroniser maintenant ». `fetch_transactions(date_from = last_synced_date - 7 jours)` (fenêtre de recouvrement), pagination `continuation_key`, puis injection dans **le même pipeline staging + dédup** que les imports fichiers (`source_kind = "api-enablebanking"`, `external_id = entry_reference` si présent, hash de contenu sinon). Les statuts `PDNG` (pending) sont ignorés ou marqués provisoires ; n'entériner que `BOOK` (booked).
- **Gestion du consentement (UX critique)** : bannière « Consentement expire le JJ/MM » + relance guidée du flux d'autorisation (échéance ~90/180 jours selon banque). Prévoir l'état « connecteur en erreur d'auth » visible sur le dashboard.
- **Multi-source sur un même compte** (fichier + API) : la dédup §6.4 est l'unique garde-fou — raison de plus pour ne jamais court-circuiter le pipeline commun.
### 8.3 Hors périmètre v2 (documenté pour v3+)
- Adaptateur **woob** optionnel (fragile, credentials en clair — §5.4).
- Import **QIF** et relevés **PDF** (OCR/extraction — projets type `LBPExtract` montrent la faisabilité pour LBP).
- **camt.053** (XML ISO 20022) si un jour utile (Firefly l'accepte ; peu diffusé côté particuliers FR).
---
## 9. Sources
Formats bancaires :
- [ScanCompte — Exporter son relevé BoursoBank (CSV/PDF/OFX)](https://www.scancompte.com/banques/exporter-releve-boursorama) ; [What In My Pocket — Export BoursoBank](https://whatinmypocket.com/guides/exporter-releve-boursobank/)
- [mincong-h/finance-toolkit](https://github.com/mincong-h/finance-toolkit) — parseurs + fichiers d'exemple réels BNP / Boursorama / Fortuneo / Revolut / Caisse d'Épargne (en-têtes cités §2), docs `docs/boursorama.md`, `docs/bnp.md`, `docs/caisse-epargne.md`
- [OpenFlyers — Modèle CSV Crédit Agricole](https://doc4-fr.openflyers.com/Mod%C3%A8le-d'import-de-relev%C3%A9-bancaire-CSV-Cr%C3%A9dit-Agricole-avec-point-virgule) ; [Modèle CSV Banque Postale](https://doc4-fr.openflyers.com/Mod%C3%A8le-d'import-de-relev%C3%A9-bancaire-CSV-Banque-Postale-avec-point-virgule) ; [Modèle CSV Société Générale](https://doc4-fr.openflyers.com/Mod%C3%A8le-d'import-de-relev%C3%A9-bancaire-CSV-Soci%C3%A9t%C3%A9-G%C3%A9n%C3%A9rale-avec-point-virgule) ; [page générale exports banques](https://doc4-fr.openflyers.com/Exporter-un-relev%C3%A9-bancaire-depuis-un-site-internet-de-banque)
- [enodev.fr — Fin du QIF à la Société Générale (structure CSV réelle)](https://enodev.fr/posts/fin-du-qif-a-la-societe-generale.html)
- [ofxpress — BNP Paribas](https://ofxpress.fr/telecharger-votre-releve-bancaire-bnp-paribas/), [La Banque Postale](https://ofxpress.fr/telecharger-votre-releve-bancaire-la-banque-postale/), [Crédit Agricole](https://ofxpress.fr/telecharger-votre-releve-bancaire-credit-agricole/)
- [Aide Caisse d'Épargne — Comment exporter mes opérations](https://www.aide.caisse-epargne.fr/contents/comment-exporter-mes-operations)
- [statementsheet — Fortuneo (formats, 10 ans)](https://statementsheet.com/how-to-convert-fortuneo-bank-statement-to-excel-csv/) ; [kdecherf — Fortuneo + woob](https://kdecherf.com/blog/2022/11/13/importer-des-transactions-fortuneo-dans-homebank-avec-woob/)
- [Lido — comparatif exports banques FR](https://www.lido.app/fr/releve-bancaire-excel) ; [MoneyVox — profondeur d'historique CSV/OFX par banque](https://www.moneyvox.fr/forums/fil/maximum-historique-des-telechargements-csv-ofx-chez-votre-banque.35927/)
- N26 : [dekodi — colonnes CSV N26](https://manuals.dekodi.de/nexuspub/datenbereitstellungsbuch/n26.html), [KontoCSV N26](https://www.kontocsv.de/en/n26)
- Revolut/N26 (bank2ynab, formats confirmés) : [bank2ynab.conf](https://github.com/bank2ynab/bank2ynab/blob/develop/bank2ynab/data/bank2ynab.conf)
PayPal :
- [PayPal Developer — Activity Download report (champs, formats, limites)](https://developer.paypal.com/docs/reports/online-reports/activity-download/) ; [PDF spec PP_ActivityDownload](https://www.paypalobjects.com/webstatic/en_US/developer/docs/pdf/PP_ActivityDownload.pdf)
- [Putler — export PayPal 2025](https://www.putler.com/export-paypal-transactions/) ; [KontoCSV — PayPal CSV](https://www.kontocsv.de/en/guides/paypal-transactions-csv)
OFX / dédup :
- [Akretion — LCL ne respecte pas la norme OFX (FITID non uniques)](https://akretion.com/fr/blog/lcl-ne-respecte-pas-la-norme-ofx)
- [Quinthar — OFX FITIDs: Not as permanent as you might think](http://blog.quinthar.com/2008/12/ofx-fitids-not-as-permanent-as-you.html) ; [OFX spec (OpenExchange, unicité par compte)](https://xml.coverpages.org/OFEXFIN1.html)
- [Firefly III — Duplicate detection (référence)](https://docs.firefly-iii.org/references/data-importer/duplicate-detection/) (source : dépôt `firefly-iii/docs`)
- [Actual Budget — Importing transactions (imported_id + fuzzy)](https://actualbudget.org/docs/transactions/importing/) ; [PR #2991 — fuzzy match vs imported_id](https://github.com/actualbudget/actual/pull/2991)
PSD2 / agrégation :
- [Actual Budget — GoCardless setup (« stopped accepting new accounts » juillet 2025, 50 connexions, 4 syncs/jour)](https://actualbudget.org/docs/advanced/bank-sync/gocardless/)
- [OpenBankingTracker — Free & Indie Open Banking APIs 2026](https://www.openbankingtracker.com/guides/free-open-banking-apis)
- [Firefly III — tutoriel Enable Banking](https://docs.firefly-iii.org/tutorials/data-importer/eb/) ; [issue #10753 — Enable Banking comme alternative à GoCardless](https://github.com/firefly-iii/firefly-iii/issues/10753)
- [Enable Banking — Linked accounts / restricted mode](https://enablebanking.com/docs/api/linked-accounts/) ; [Enable Banking — marché France](https://enablebanking.com/docs/markets/fr/) ; [FAQ](https://enablebanking.com/docs/faq/)
- [Powens](https://www.powens.com/fr/plateforme/) ; [Bridge (openfinanceguide)](https://openfinanceguide.com/en/glossary/bridge) ; [BPI — mapping open banking FR 2025](https://bigmedia.bpifrance.fr/nos-actualites/mapping-2025-des-acteurs-francais-de-lopen-banking)
- [woob (GitLab)](https://gitlab.com/woob/woob) ; [issue #803 — sync BoursoBank cassée](https://gitlab.com/woob/woob/-/issues/803)
- [Firefly III — import-configurations communautaires (profils fr/boursorama, fr/fortuneo)](https://github.com/firefly-iii/import-configurations)
+273
View File
@@ -0,0 +1,273 @@
# Recherche : intégrer les données Android Health Connect dans LifeTrack (auto-hébergé)
> Document de recherche — module Santé/Fitness de LifeTrack.
> Date : 2026-08-13. Public : agents d'implémentation (backend FastAPI, frontend React, app Android éventuelle).
> Langue : prose en français, identifiants de code en anglais (convention projet).
---
## 1. Résumé exécutif
**Constat central : Health Connect est un magasin de données strictement local au téléphone ("on-device"). Il n'existe aucune API cloud officielle permettant à un serveur (auto-hébergé ou non) d'interroger les données Health Connect d'un utilisateur.** Le seul moyen d'amener ces données sur le serveur LifeTrack est qu'une application Android installée sur le téléphone lise Health Connect localement puis pousse les données vers notre backend (ou exporte des fichiers que l'on importe).
**L'API REST Google Fit est morte** : inscriptions fermées depuis le 1er mai 2024, arrêt définitif courant 2026. Elle ne doit servir de base à rien dans LifeTrack.
**Recommandation v1** (détaillée en section 8) :
1. Construire côté LifeTrack un **endpoint d'ingestion REST générique et authentifié** (`POST /api/v1/ingest/...`), conçu pour recevoir des lots de mesures santé en JSON, avec stockage du payload brut + normalisation, idempotent (dédoublonnage par identifiant externe).
2. Utiliser comme pont v1 l'application open-source **health-connect-webhook** (Android, AGPL-3.0, disponible sur le Play Store), qui lit Health Connect en tâche de fond (WorkManager) et POSTe du JSON vers des URLs de webhook configurables — c'est exactement le modèle "push vers endpoint custom" voulu par le brief. Alternative plus lourde : **HCGateway** (serveur Flask+MongoDB à héberger en plus, moins bien aligné avec notre stack).
3. Prévoir en **fallback un import de fichiers CSV** : l'application payante **Health Sync** (licence unique ~4 €) exporte automatiquement les données Health Connect en CSV (et les séances en FIT/TCX/GPX) vers Google Drive ; LifeTrack fournit un importeur CSV correspondant via le framework de connecteurs.
4. **v2** : application companion Android minimale maison (Kotlin + Health Connect SDK + WorkManager) qui POSTe directement sur notre endpoint — fiabilité et contrôle maximum, effort modéré (3 à 10 jours). **v2/v3** : enregistrement direct des séances de tapis via **Web Bluetooth + FTMS** dans le frontend React (Chrome/Edge uniquement, HTTPS requis), pour s'affranchir de l'app FitShow.
---
## 2. Health Connect : fonctionnement, capacités, limites
### 2.1 Architecture : on-device uniquement, pas d'API cloud
- Health Connect (HC) est une base de données chiffrée **stockée sur le téléphone**. Les apps santé (Samsung Health, Fitbit, Gadgetbridge, Foodvisor via Google Fit, etc.) y écrivent ; d'autres apps y lisent, **uniquement depuis le device**.
- La documentation officielle Google est explicite : HC est prévu pour les données "on-device" Android ; la « Google Health API » (cloud) est un produit distinct, successeur de la **Fitbit Web API** uniquement (comptes Fitbit/Google, accès soumis à validation Google) — **inutilisable pour un projet personnel auto-hébergé** et hors périmètre.
- Conséquence d'architecture pour LifeTrack : **un intermédiaire Android est obligatoire**. Le backend ne pourra jamais "aller chercher" les données ; il doit **recevoir** (push HTTP) ou **importer** (fichiers).
- Depuis Android 14, HC est **intégré au système** (Réglages > Sécurité et confidentialité > Health Connect). Sur Android 913, c'est une app APK à installer depuis le Play Store. SDK minimum : API 28 (Android 9).
### 2.2 Types de données disponibles (Jetpack `androidx.health.connect.client.records`)
Tous les besoins du module Santé/Fitness de LifeTrack sont couverts par des types HC standards. Correspondance à utiliser telle quelle dans le modèle de données :
| Besoin LifeTrack | Record Health Connect | Permission Android |
|---|---|---|
| Pas quotidiens | `StepsRecord` | `android.permission.health.READ_STEPS` |
| Distance | `DistanceRecord` | `...READ_DISTANCE` |
| Calories actives brûlées | `ActiveCaloriesBurnedRecord` | `...READ_ACTIVE_CALORIES_BURNED` |
| Calories totales brûlées (TDEE observé) | `TotalCaloriesBurnedRecord` | `...READ_TOTAL_CALORIES_BURNED` |
| Métabolisme de base (BMR) | `BasalMetabolicRateRecord` | `...READ_BASAL_METABOLIC_RATE` |
| Séances de sport (tapis, etc.) | `ExerciseSessionRecord` (+ `SpeedRecord`, `PowerRecord`, `ElevationGainedRecord` associés) | `...READ_EXERCISE` (+ `READ_SPEED`, `READ_POWER`) |
| Poids | `WeightRecord` | `...READ_WEIGHT` |
| Masse grasse | `BodyFatRecord` | `...READ_BODY_FAT` |
| Masse maigre / osseuse / hydrique | `LeanBodyMassRecord`, `BoneMassRecord`, `BodyWaterMassRecord` | permissions dédiées |
| Taille | `HeightRecord` | `...READ_HEIGHT` |
| Fréquence cardiaque | `HeartRateRecord`, `RestingHeartRateRecord`, `HeartRateVariabilityRmssdRecord` | `...READ_HEART_RATE`, etc. |
| Sommeil | `SleepSessionRecord` (avec stages) | `...READ_SLEEP` |
| Apport calorique / macros (Foodvisor) | `NutritionRecord` | `...READ_NUTRITION` |
| Hydratation | `HydrationRecord` | `...READ_HYDRATION` |
| VO2 max, étages montés, etc. | `Vo2MaxRecord`, `FloorsClimbedRecord`, ... | permissions dédiées |
Note Foodvisor : Foodvisor (Android) se synchronise avec **Google Fit** (réglage « connexion aux données de santé ») ; Google Fit écrit/lit la nutrition via Health Connect. La chaîne Foodvisor → Google Fit → Health Connect → pont → LifeTrack est donc **possible en théorie pour les calories ingérées** (`NutritionRecord`), mais fragile (dépend du maintien de l'app Google Fit, en fin de vie côté API ; des utilisateurs signalent des synchronisations Foodvisor→Fit capricieuses). À tester en priorité une fois le pont en place ; sinon, saisie manuelle / export Foodvisor en fallback.
### 2.3 Permissions et restrictions importantes (impact direct sur la conception)
- **Fenêtre historique de 30 jours** : par défaut, une app ne peut lire que les données datant d'au plus **30 jours avant la première autorisation**. Pour lire plus ancien, permission additionnelle `android.permission.health.READ_HEALTH_DATA_HISTORY` (`PERMISSION_READ_HEALTH_DATA_HISTORY`), disponible depuis les mises à jour 2025 du SDK (Jetpack en bêta depuis mars 2025). Conséquence : installer/configurer le pont **tôt** ; l'historique antérieur profond passera plutôt par des exports CSV.
- **Lecture en arrière-plan** : permission dédiée `android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND`, indispensable pour un pont qui synchronise sans que l'app soit ouverte. Les apps pont citées la gèrent déjà ; une app maison doit la déclarer et la demander.
- **Rate limiting** : HC impose des quotas de lecture (par app, différents premier plan / arrière-plan). Conception à base de **sync incrémentale** obligatoire, pas de relecture complète à chaque cycle.
- **API de changements (differential changes)** : `getChanges(token)` fournit ajouts/modifications/suppressions depuis le dernier token — c'est le mécanisme officiel de sync incrémentale. Les tokens expirent (~30 jours) ; prévoir une resynchronisation de rattrapage si token expiré.
- **Distribution hors Play Store** : la validation Google (formulaire de déclaration des permissions santé) est une exigence **de publication sur le Play Store**, pas un prérequis technique de l'API. Une app companion **sideloadée** (APK debug/release signé localement) déclarant correctement ses permissions dans le manifest et l'intent de justification (`androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` sur Android 13-, `android.intent.action.VIEW_PERMISSION_USAGE` + catégorie `android.intent.category.HEALTH_PERMISSIONS` sur 14+) fonctionne : l'utilisateur accorde les permissions dans l'UI Health Connect. Preuve empirique : HCGateway et d'autres ponts open-source se distribuent en APK GitHub et fonctionnent. Point de vigilance : certains guides tiers évoquent des contrôles d'app ID pour les apps Play ; pour un usage personnel sideloadé, aucun blocage connu, mais **à valider sur l'appareil cible dès le début du développement v2**.
### 2.4 Ce que ça implique pour LifeTrack
- Le backend doit exposer une **API d'ingestion push** (section 8) — jamais de « pull » possible.
- Les données arrivent par lots hétérogènes, avec doublons possibles (re-synchronisations) : l'ingestion doit être **idempotente**.
- Chaque record HC porte des métadonnées utiles à conserver : `metadata.id` (UUID unique HC), `clientRecordId`, `dataOrigin` (package de l'app source, ex. `com.sec.android.app.shealth`), horodatages début/fin avec offset de zone. LifeTrack stocke en UTC (convention projet) + `source_app`.
---
## 3. API REST Google Fit : statut (chemin non viable)
- **Dépréciation annoncée** avec Health Connect comme successeur ; **inscriptions fermées depuis le 1er mai 2024** (aucun nouveau projet ne peut obtenir l'accès OAuth aux scopes Fitness).
- **Arrêt de service courant 2026** ("supported until the end of 2026" selon la FAQ de migration ; la page officielle developers.google.com/fit dit "will be deprecated in 2026" sans jour précis).
- L'app mobile Google Fit elle-même est en fin de vie au profit de l'app Fitbit ; ne pas en faire une dépendance.
- **Verdict : à exclure totalement.** Même si un projet OAuth existant fonctionnait encore quelques mois, tout investissement serait perdu. Aucune ligne de code LifeTrack ne doit cibler l'API Google Fit.
---
## 4. Applications pont existantes (Health Connect → serveur/fichier)
### 4.1 health-connect-webhook (mcnaveen) — candidat recommandé v1
- **Dépôt** : https://github.com/mcnaveen/health-connect-webhook — Kotlin / Jetpack Compose / Material 3, licence **AGPL-3.0** (+ addendum commercial pour la redistribution en store). ~141 stars, ~161 commits, activement maintenu, distribué sur le **Play Store** (et App Store côté iOS).
- **Fonction** : lit Health Connect et **POSTe un JSON vers une ou plusieurs URLs de webhook** configurées par l'utilisateur. Exactement le modèle "push vers endpoint REST custom" du brief.
- **Mécanismes de sync** : (a) périodique via **WorkManager** (minimum 15 min, réglable), (b) horaires fixes via AlarmManager (défaut 08:00 et 21:00), (c) sync manuelle, (d) **serveur HTTP local optionnel** sur le téléphone (port 8787 par défaut) exposant un snapshot JSON en pull sur le LAN.
- **Payload** : `POST` avec `Content-Type: application/json; charset=utf-8`. Objet JSON unique : `timestamp` (génération du payload), `app_version`, puis un **tableau par type de données en snake_case** (ex. `steps`, `heart_rate`, `weight`, `exercise_sessions`, `sleep_sessions`, `nutrition`, `active_calories_burned`, `total_calories_burned`, `distance`...), tableaux omis si vides. Schémas de champs détaillés dans les fichiers `docs/webhook.md` et `docs/local-http.md` du dépôt (les récupérer au moment de l'implémentation de l'adaptateur pour figer le mapping exact).
- **Couverture** : **31 types de données**, incluant tout ce dont LifeTrack a besoin (steps, distance, calories actives/totales, exercise sessions, poids, masse grasse, BMR, FC, sommeil avec stages, nutrition, hydratation, VO2 max).
- **Fenêtre de lecture** : fenêtre glissante de **48 h** ; les syncs en arrière-plan sont incrémentales (données nouvelles depuis la dernière sync réussie). Retries : 3 tentatives avec backoff exponentiel, puis nouvelle tentative à la sync suivante. **Limite** : si le téléphone/app est inactif plus de 48 h, trou possible → couvert par le fallback CSV et par la réconciliation côté serveur.
- **Limite importante : pas d'en-tête d'authentification configurable.** La sécurité repose sur l'URL. Mitigation LifeTrack : jeton secret **dans l'URL** (ex. `https://lifetrack.example/api/v1/ingest/hc-webhook/<ingest_token>`), token révocable, endpoint accessible uniquement en HTTPS (et idéalement seulement via VPN/LAN — déploiement domestique). Vérifier à l'implémentation si des en-têtes custom ont été ajoutés depuis.
### 4.2 HCGateway (ShuchirJ) — alternative complète mais lourde
- **Dépôt** : https://github.com/ShuchirJ/HCGateway — licence **GPL-3.0**, ~414 stars, développement actif ("API stability not guaranteed").
- **Architecture** : app mobile **React Native** (Android 8+) + **serveur Python Flask + MongoDB** auto-hébergeable (Docker Compose fourni) + **Firebase** (notifications push, nécessaire seulement pour déclencher des écritures serveur→téléphone ; il faut alors builder l'APK soi-même avec son `google-services.json`).
- **Fonction** : sync **bidirectionnelle** — l'app envoie ~34/35 types de données HC vers le serveur toutes les **2 h** (réglable, service foreground persistant, sync ~15 min) ; API REST (login/signup, fetch par type — méthodes nommées `steps`, `heartRate`, `sleepSession`, `activeCaloriesBurned`, `weight`, `exerciseSession`, etc. — et push vers HC). Docs API : https://hcgateway.shuchir.dev/.
- **Sécurité** : mots de passe Argon2 ; données chiffrées **Fernet** au repos dans MongoDB (clé dérivée du hash utilisateur), déchiffrées à la volée lors des requêtes API. Collections `hcgateway_[user_id]` avec données chiffrées, horodatages début/fin, package d'origine, id unique.
- **Historique** : limité aussi par la fenêtre HC de 30 jours (issue GitHub #38 ouverte à ce sujet).
- **Évaluation pour LifeTrack** : fonctionne, mais impose **un deuxième backend + MongoDB + éventuellement Firebase** à côté de notre stack Postgres/FastAPI, et LifeTrack devrait **poller** l'API HCGateway (au lieu de recevoir un push). Intéressant seulement si health-connect-webhook s'avère défaillant. Une instance publique existe (`https://api.hcgateway.shuchir.dev/`) mais envoie les données santé chez un tiers — contraire à l'esprit auto-hébergé.
### 4.3 Health Sync (appyhapps.nl) — le fallback fichiers de référence
- App Android commerciale mature (https://healthsync.app) : essai 1 semaine, puis **licence à vie en achat unique (~4 €)** ou abonnement 6 mois (Withings seul nécessite un abonnement dédié).
- Synchronise entre plateformes (sources : Health Connect, Samsung Health, Fitbit, Garmin, Polar, Suunto, Huawei, Oura, Strava, fatsecret... ; destinations : Health Connect, Strava, Google Drive, etc.).
- **Fonction clé pour LifeTrack : export automatique vers Google Drive** — données santé (pas, FC, poids...) en **CSV** (fichiers jour courant / 7 jours / mois / 30 jours glissants), séances d'activité en **FIT, TCX, GPX, KML, CSV**. Tourne en arrière-plan sans intervention.
- Usage LifeTrack : l'utilisateur dépose les CSV dans l'UI d'import (ou un dossier synchronisé sur le serveur) ; le framework d'importeurs LifeTrack fournit un parseur `health_sync_csv`. Sert aussi à **rattraper l'historique profond** (au-delà des 30 jours HC) et les trous de sync.
- Fermé/propriétaire : formats CSV à rétro-ingénierer sur échantillons réels (colonnes stables, une ligne par mesure horodatée ; prévoir l'importeur tolérant : détection d'en-têtes + mapping configurable).
### 4.4 Health Data Export (teqxnology / healthdataexport.com)
- App d'export manuel/planifié : Apple Health, Health Connect, Google Fit → **CSV, JSON, PDF, Excel**. Utile ponctuellement pour un dump massif initial ; moins adaptée à une sync continue. Second choix derrière Health Sync pour le fallback fichiers.
### 4.5 Home Assistant (app companion Android)
- Depuis la version **2025.5**, l'app companion Home Assistant expose des **capteurs Health Connect** (pas, calories actives/totales, distance, sommeil, poids, FC... ; liste élargie par vagues de 6 capteurs, cf. release notes github.com/home-assistant/android). Flux : app santé → HC → capteurs HA.
- Limites : capteurs = **valeurs instantanées/agrégats du moment**, pas des séries historiques propres ; il faudrait ensuite extraire du recorder HA vers LifeTrack (REST API HA + long-lived token). Convoluté, granularité pauvre (pas de séances détaillées, pas de nutrition complète). **Pertinent uniquement si l'utilisateur exploite déjà Home Assistant** et seulement pour des métriques simples (pas quotidiens). Non retenu comme chemin principal.
### 4.6 Gadgetbridge
- Gadgetbridge (app FLOSS pour montres/bracelets) est une **source** Health Connect, pas un exporteur : depuis la 0.89.0, Réglages > External Integrations > Health Connect permet de pousser **Steps et Heartbeat** (types supportés à ce jour) vers HC, localement, sans cloud constructeur.
- Intérêt LifeTrack : si l'utilisateur passe un jour à une montre supportée par Gadgetbridge, ses données rejoignent HC puis LifeTrack via le pont existant — **aucun travail supplémentaire côté LifeTrack**. Gadgetbridge offre aussi ses propres exports (base SQLite, auto-export) mais ce n'est pas le sujet v1.
### 4.7 Tableau comparatif des ponts
| Solution | Type | Push vers REST custom ? | Auth | Types couverts | Coût | Maintenance/risque |
|---|---|---|---|---|---|---|
| health-connect-webhook | App open-source (AGPL) | **Oui (webhooks POST JSON)** | Token dans URL seulement | 31 | Gratuit | Actif ; projet jeune |
| HCGateway | App + serveur open-source (GPL) | Non (LifeTrack pollerait son API) | Login + tokens | ~34 | Gratuit | Actif ; stack Flask/MongoDB/Firebase en plus |
| Health Sync | App commerciale | Non (fichiers vers Drive) | n/a | Large | ~4 € une fois | Très mature |
| Health Data Export | App commerciale | Non (fichiers) | n/a | Large | Freemium | OK |
| Home Assistant companion | App open-source | Indirect (via HA) | Token HA | Partiel (capteurs) | Gratuit | Actif |
| Gadgetbridge | App open-source | Non (c'est une source HC) | n/a | Steps, FC | Gratuit | Très actif |
---
## 5. FitShow (tapis de course) et la piste FTMS
### 5.1 L'app FitShow : capacités et limites
- FitShow (`com.fitshow` sur le Play Store) est l'app officielle des équipements embarquant le module Bluetooth **FitShow SmartBTM** (tapis, vélos, elliptiques, rameurs — marques low-cost/moyennes très répandues). Modes cartes, programmes, objectifs, podomètre.
- **iOS** : écrit pas et distance dans **Apple Health** (HealthKit) — sans objet pour nous.
- **Android** : **aucune intégration Health Connect ni Google Fit documentée**, **pas d'export Strava** (demandé par les utilisateurs, non implémenté), pas d'export de fichiers (TCX/GPX/FIT) connu. Compte cloud FitShow sans API publique.
- **Verdict : ne pas compter sur FitShow comme source de données.** Les séances tapis faites dans FitShow resteront enfermées. Trois contournements : (a) saisie manuelle de la séance dans LifeTrack (v1), (b) enregistrer la séance via une app qui écrit dans HC (ex. app de sport compatible FTMS, ou QZ ci-dessous), (c) **capter le tapis directement en BLE** (v2/v3, ci-dessous).
### 5.2 Protocoles BLE : FS (propriétaire) vs FTMS (standard)
- **FTMS** (Fitness Machine Service, Bluetooth SIG) est le standard BLE des machines de fitness : service `0x1826`, caractéristique **Treadmill Data `0x2ACD`** (notifications : vitesse instantanée en 0,01 km/h, distance totale, inclinaison en 0,1 %, calories, temps écoulé, FC si dispo — champs présents selon un bitfield de flags), Fitness Machine Control Point `0x2AD9` (contrôle vitesse/inclinaison), Fitness Machine Feature `0x2ACC`. Supporté par Zwift, Kinomap, Wahoo, Polar, etc.
- Les machines équipées FitShow parlent le **protocole propriétaire "FS"** ; **beaucoup de modèles récents exposent aussi FTMS** (les compatibilités Zwift/Kinomap l'attestent). À vérifier sur le tapis de l'utilisateur avec un scanner BLE (nRF Connect : chercher le service `0x1826`). Attention : le tapis n'accepte en général **qu'une connexion BLE à la fois** (FitShow app OU LifeTrack, pas les deux).
- **QZ (qdomyos-zwift)**, open-source (https://github.com/cagnulein/qdomyos-zwift), sait parler le protocole FS propriétaire de nombreux tapis et **re-exposer un device FTMS virtuel** ; il pousse aussi vers Strava/Peloton/Garmin. C'est le plan B si le tapis n'expose pas FTMS nativement.
### 5.3 Web Bluetooth dans le frontend LifeTrack : faisable
- **Oui, un frontend web peut enregistrer une séance de tapis en direct via Web Bluetooth + FTMS.** Des simulateurs de vélo open-source dans le navigateur le font déjà avec des trainers FTMS ; l'API `navigator.bluetooth.requestDevice({ filters: [{ services: [0x1826] }] })` puis abonnement aux notifications de `0x2ACD` suffit pour un tapis.
- **Contraintes** :
- Navigateurs : **Chrome/Edge (desktop et Android) uniquement** — pas Firefox ni Safari. Acceptable pour un outil personnel ; à documenter dans l'UI.
- **Contexte sécurisé requis** : HTTPS (ou `localhost`). Le déploiement domestique nginx devra servir en HTTPS (mkcert / CA locale / certificat Let's Encrypt si domaine) pour que le bouton "Connecter le tapis" fonctionne depuis un autre appareil que le serveur.
- Geste utilisateur requis pour l'appairage (pas de connexion silencieuse au chargement) ; reconnexion à gérer.
- **Design proposé (v2/v3)** : composant React `TreadmillRecorder` — connexion FTMS, échantillonnage ~1 Hz (vitesse, distance cumulée, inclinaison, kcal), graphe live ECharts, à l'arrêt POST de la séance vers `/api/v1/workouts` (durée, distance, vitesse moy/max, kcal, série de points). Valeur : remplace complètement FitShow pour le tapis et alimente directement le bilan énergétique. Effort : ~1-2 semaines avec l'UI.
---
## 6. App companion Android maison (chemin v2 privilégié)
### 6.1 Faisabilité et briques techniques
- **SDK** : Jetpack `androidx.health.connect:connect-client` (bêta depuis mars 2025, stable pour les usages courants). Kotlin, `minSdk 28` (HC APK requis sur Android 9-13 ; natif sur 14+).
- **Manifest** : déclarer chaque permission `android.permission.health.READ_*` nécessaire + `READ_HEALTH_DATA_IN_BACKGROUND` + `READ_HEALTH_DATA_HISTORY` ; intent de justification : activité gérant `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` (Android 13-) et alias avec `android.intent.action.VIEW_PERMISSION_USAGE` + catégorie `android.intent.category.HEALTH_PERMISSIONS` (Android 14+), affichant la politique de confidentialité (une page statique en français suffit pour un usage personnel).
- **Sync** : `PeriodicWorkRequest` WorkManager (minimum Android : **15 min** ; 30-60 min suffisent pour LifeTrack), avec contrainte réseau. Première exécution : lecture complète 30 jours (ou plus avec la permission history) par `readRecords` paginé ; ensuite **sync incrémentale via `getChangesToken` / `getChanges`** (gère ajouts/modifs/suppressions, économise le quota de rate limiting). Stocker le token ; si expiré (~30 jours), refaire un rattrapage borné.
- **Envoi** : POST JSON (OkHttp/Ktor) vers l'endpoint d'ingestion LifeTrack avec **Bearer token** (vraie authentification, contrairement à health-connect-webhook) ; file de retry persistée (Room ou fichier) pour tolérer serveur éteint / hors LAN ; option "sync seulement en Wi-Fi domestique".
- **Distribution** : APK signé localement, sideloadé — **pas de compte développeur Google ni de revue Play nécessaire** pour un usage personnel (cf. 2.3, à valider sur l'appareil cible en tout début de v2).
### 6.2 Estimation d'effort
| Poste | Estimation |
|---|---|
| Squelette app (Compose, 2 écrans : config serveur/token, état de sync) | 1 jour |
| Intégration HC : permissions + lecture des ~10 types utiles | 1-2 jours |
| WorkManager + changes API + file de retry | 1-2 jours |
| Client HTTP + mapping JSON vers le schéma d'ingestion | 0,5-1 jour |
| Tests sur device réel, edge cases (Doze, OEM battery killers), polish | 1-3 jours |
| **Total** | **~3-5 jours** (dev Android expérimenté) à **~2 semaines** (montée en compétence incluse) |
Risques spécifiques Android : optimisations batterie agressives des OEM (Xiaomi/Huawei/Samsung) pouvant tuer WorkManager → documenter l'exclusion de l'optimisation batterie ; quotas HC en arrière-plan → rester sur la changes API.
---
## 7. Classement des chemins d'intégration (effort vs valeur)
| # | Chemin | Effort | Valeur | Fiabilité | Version cible |
|---|---|---|---|---|---|
| 1 | **Endpoint d'ingestion REST générique LifeTrack** (prérequis de tout le reste) | Moyen (backend pur) | Très élevée | n/a | **v1** |
| 2 | **health-connect-webhook → endpoint LifeTrack** | Faible (adaptateur de payload) | Élevée (sync auto ~15 min, 31 types) | Moyenne+ (fenêtre 48 h, pas d'auth header) | **v1** |
| 3 | **Import CSV Health Sync (fallback + historique)** | Faible-moyen (parseurs CSV/TCX) | Élevée (rattrapage, robustesse) | Élevée | **v1** |
| 4 | Saisie manuelle poids/séances/nutrition | Déjà prévu (UI) | Élevée | Élevée | v1 |
| 5 | **App companion maison (HC SDK + WorkManager + Bearer)** | Moyen (3-10 j) | Très élevée (contrôle total, auth propre, >48 h, history) | Élevée | **v2** |
| 6 | **Web Bluetooth FTMS (enregistrement tapis dans le navigateur)** | Moyen (1-2 sem.) | Élevée (remplace FitShow, données riches) | Moyenne (Chrome/Edge + HTTPS + FTMS dispo sur le tapis) | **v2/v3** |
| 7 | HCGateway auto-hébergé | Moyen-élevé (2e stack serveur) | Moyenne (doublonne #2/#5) | Moyenne | plan B uniquement |
| 8 | Home Assistant companion → HA → LifeTrack | Moyen | Faible (granularité pauvre) | Moyenne | non retenu |
| 9 | Gadgetbridge (source HC si montre compatible) | Nul côté LifeTrack | Bonus | Élevée | opportuniste |
| 10 | Google Fit REST API | — | **Nulle (arrêt 2026, inscriptions fermées)** | — | **exclu** |
---
## 8. Recommandation v1 détaillée + contrat d'ingestion proposé
### 8.1 Périmètre v1
1. **API d'ingestion générique** (ci-dessous) + table de payloads bruts + normalisation vers les tables métier (`weight_measurements`, `daily_activity`, `workouts`, `nutrition_entries`, ...).
2. **Adaptateur health-connect-webhook** : endpoint dédié acceptant le format de cette app, token dans l'URL.
3. **Importeurs fichiers** via le framework de connecteurs : `health_sync_csv` (pas, poids, FC, calories), `tcx`/`gpx` (séances). Réutilisables pour tout autre export.
4. Documentation utilisateur (français) : installer health-connect-webhook depuis le Play Store, coller l'URL d'ingestion générée par LifeTrack (avec token), cocher les types de données, régler l'intervalle ; configurer Health Sync en secours.
### 8.2 Contrat API d'ingestion (à implémenter tel quel)
Principes : **push only, idempotent, tolérant, brut d'abord**.
- `POST /api/v1/ingest/health` — endpoint canonique (utilisé par la future app companion v2 et tout client "propre").
- Auth : `Authorization: Bearer <ingest_token>` (token d'ingestion par device, distinct du JWT de session, révocable, stocké haché).
- Corps : `{ "source": "companion-app", "device_id": "...", "records": [ { "type": "steps", "external_id": "<hc metadata.id>", "start_time": "...Z", "end_time": "...Z", "value": {...}, "unit": "...", "origin_app": "com.sec.android.app.shealth" }, ... ] }`.
- Réponse : `{ "accepted": n, "duplicates": m, "rejected": [...] }` ; `207`-like sémantique, jamais d'échec global pour un record invalide.
- `POST /api/v1/ingest/hc-webhook/{ingest_token}`**adaptateur health-connect-webhook** (l'app ne sait pas poser d'en-tête d'auth → token dans le chemin, transmis en HTTPS uniquement). Accepte le payload natif de l'app (objet avec `timestamp`, `app_version`, tableaux snake_case par type), le stocke brut, puis mappe les types connus vers le pipeline canonique.
- **Stockage brut systématique** : table `raw_ingest_payloads` (id, token/device, received_at UTC, source, payload JSONB, processing_status, error). Permet de rejouer la normalisation quand le mapping s'affine — crucial car le schéma exact des ponts tiers peut évoluer.
- **Idempotence / dédoublonnage** : contrainte unique `(user_id, record_type, external_id)` quand un id externe existe (UUID `metadata.id` HC, transmis par les ponts) ; sinon clé de repli = hash SHA-256 de `(record_type, start_time, end_time, origin_app, valeur canonique)`. Les re-syncs (fenêtre 48 h de health-connect-webhook, ré-imports CSV) deviennent inoffensives.
- **Fuseaux** : entrées horodatées ISO-8601 avec offset ; conversion et stockage **UTC** ; agrégats journaliers calculés en `Europe/Paris` côté requêtes/vues.
- Sécurité déploiement domestique : HTTPS obligatoire (nginx), rate limit simple sur les endpoints d'ingestion, tokens révocables depuis l'UI (page « Sources de données »), logs d'ingestion visibles dans l'UI pour diagnostiquer les trous de sync.
### 8.3 Ordre de vérification à l'implémentation (sans nouvelle recherche produit)
1. Figer le mapping exact des champs de health-connect-webhook depuis `docs/webhook.md` du dépôt (et/ou capturer un payload réel avec l'app pointée vers un endpoint de debug).
2. Générer des exports Health Sync réels (CSV jour/semaine/mois + un TCX de séance) et figer les parseurs sur ces échantillons.
3. Tester la chaîne Foodvisor → Google Fit → Health Connect → pont pour `NutritionRecord` ; si KO, la saisie calories reste manuelle en v1.
4. Scanner le tapis avec nRF Connect pour confirmer la présence du service FTMS `0x1826` (décide la faisabilité du chemin Web Bluetooth v2/v3 sans QZ).
---
## 9. Risques et inconnues
- **health-connect-webhook** : projet jeune ; schéma de payload non contractuel (d'où le stockage brut + adaptateur isolé) ; absence d'auth par en-tête (token en URL + HTTPS/VPN en mitigation) ; fenêtre 48 h (trous possibles, couverts par CSV).
- **Comportement OEM Android** (Doze, battery killers) : peut espacer les syncs de n'importe quel pont ou de l'app maison ; documenter l'exclusion d'optimisation batterie.
- **Fenêtre historique HC de 30 jours** : l'historique profond ne viendra jamais de HC sans `READ_HEALTH_DATA_HISTORY` (et jamais au-delà de ce que les apps sources ont écrit dans HC) → import CSV pour le passé.
- **Sideload + permissions HC sur l'appareil cible** : à valider empiriquement en tout début de v2 (aucun blocage connu, mais politique Google mouvante en 2025-2026).
- **FitShow** : silo confirmé côté Android ; la valeur du chemin FTMS dépend du matériel réel de l'utilisateur (présence du service `0x1826`).
- **Foodvisor → HC** : chaîne indirecte via Google Fit, app Google Fit en fin de vie ; fiabilité incertaine.
- **Écosystème mouvant** : Google migre l'écosystème (Fit → Health Connect / Google Health API) ; revalider les politiques HC (permissions, quotas) au démarrage de la v2 companion.
---
## 10. Sources
- Health Connect — guide de comparaison (on-device vs cloud) : https://developer.android.com/health-and-fitness/health-connect/comparison-guide
- Health Connect — démarrage : https://developer.android.com/health-and-fitness/health-connect/get-started
- Health Connect — synchronisation (changes API) : https://developer.android.com/health-and-fitness/health-connect/sync-data
- Health Connect — types de données et permissions : https://developer.android.com/health-and-fitness/health-connect/data-types
- Health Connect — lecture de données (fenêtre 30 jours) : https://developer.android.com/health-and-fitness/guides/health-connect/develop/read-data
- Health Connect — FAQ : https://developer.android.com/health-and-fitness/guides/health-connect/frequently-asked-questions
- Health Connect — rate limiting : https://developer.android.com/health-and-fitness/health-connect/rate-limiting
- Blog Android Developers (mars 2025) — SDK Jetpack bêta, background reads, history : https://android-developers.googleblog.com/2025/03/health-connect-jetpack-sdk-now-in-beta.html
- Android Authority — historical/background reads : https://www.androidauthority.com/health-connect-historical-background-reads-3443726/
- Publication Play / déclaration santé : https://developer.android.com/health-and-fitness/health-connect/publish et https://support.google.com/googleplay/android-developer/answer/12991134
- Google Fit — page officielle (dépréciation) : https://developers.google.com/fit
- Google Fit — FAQ migration : https://developer.android.com/health-and-fitness/health-connect/migration/fit/faq
- HCGateway : https://github.com/ShuchirJ/HCGateway/ (docs API : https://hcgateway.shuchir.dev/ ; issue 30 jours : https://github.com/ShuchirJ/HCGateway/issues/38)
- health-connect-webhook : https://github.com/mcnaveen/health-connect-webhook
- Health Sync : https://healthsync.app/about/ et https://play.google.com/store/apps/details?id=nl.appyhapps.healthsync
- Health Data Export : https://healthdataexport.com/
- Home Assistant companion — capteurs Health Connect : https://github.com/home-assistant/android/releases/tag/2025.5.3 ; https://community.home-assistant.io/t/health-and-fitness-data-into-home-assistant-ha-companion-app-and-health-connect/905477
- Gadgetbridge — intégration Health Connect : https://gadgetbridge.org/basics/integrations/health-connect/ ; https://gadgetbridge.org/blog/release-0_89_00/
- FitShow (Play Store) : https://play.google.com/store/apps/details?id=com.fitshow ; (App Store, sync Apple Health) : https://apps.apple.com/us/app/fitshow-treadmill-workout/id1387360716
- FTMS — présentation Decathlon Digital : https://medium.com/decathlondigital/take-control-of-your-fitness-machines-6588439aeeda ; intégration apps : https://www.fitscope.com/blog/bluetooth-ftms-integration-for-fitness-apps
- QZ (qdomyos-zwift) : https://github.com/cagnulein/qdomyos-zwift
- Foodvisor (Play Store, sync Google Fit) : https://play.google.com/store/apps/details?id=io.foodvisor.foodvisor
+298
View File
@@ -0,0 +1,298 @@
# Recherche — Sources de données NUTRITION / CALORIES pour LifeTrack
> Document de recherche destiné aux agents d'implémentation. Rédigé le 2026-08-13.
> Langue : prose en français, identifiants de code en anglais (convention projet).
> Périmètre : ingestion des données alimentaires (repas, calories, macros) dans le module Santé/Fitness.
---
## 1. Résumé exécutif
| Source | Accès | Verdict v1 |
|---|---|---|
| Foodvisor — export in-app / RGPD | CSV limité, sur demande, pas d'automatisation | **Importeur CSV "best effort" + procédure RGPD documentée** (import ponctuel d'historique) |
| Foodvisor — API consommateur | **N'existe pas** | Hors périmètre |
| Foodvisor — Vision API (analyse photo) | Produit entreprise, tarif non public, contrat commercial | **Non viable v1** ; garder un port d'extension `photo_analyzer` |
| Foodvisor — via Health Connect | Non confirmé (Foodvisor Android se synchronise à Google Fit, pas à Health Connect de façon documentée) | Chemin indirect **à tester** via Health Sync ; ne pas en dépendre |
| Health Connect `NutritionRecord` (bridge Android) | Complet, structuré, sur l'appareil uniquement (pas d'API cloud) | **OUI — endpoint REST d'ingestion + app/bridge Android** (stratégie cible) |
| Open Food Facts API | Gratuit, ouvert (ODbL), code-barres + recherche | **OUI — pilier de la saisie manuelle assistée** |
| CIQUAL (ANSES) | Téléchargement Excel/XML, licence ouverte | **OUI — table locale d'aliments génériques français** |
| MyFitnessPal / Cronometer / Yazio — exports CSV | Formats variés, semi-documentés | Importeurs CSV **optionnels** (framework générique, mapping par profil) |
| Saisie manuelle rapide | — | **OUI — socle indispensable** |
**Stratégie v1 recommandée** : saisie manuelle rapide + recherche produit Open Food Facts (proxy backend + cache) + table CIQUAL locale pour les aliments génériques + framework d'importeurs CSV (Foodvisor, MyFitnessPal, Cronometer) + endpoint REST `POST /api/v1/ingest/nutrition` consommé par un bridge Health Connect. Un connecteur Foodvisor "direct" reste impossible aujourd'hui faute d'API ; on préserve la possibilité future via l'architecture connecteurs (section 8).
---
## 2. Foodvisor (l'app utilisée par l'utilisateur)
### 2.1 Ce que Foodvisor est (et n'est pas)
Foodvisor (société française, éditeur de l'app `io.foodvisor.foodvisor` sur Android et `id1064020872` sur iOS) est une app grand public de comptage de calories par photo (IA de reconnaissance alimentaire), scan de code-barres et saisie manuelle, avec coaching payant. **Il n'existe aucune API consommateur** (pas d'OAuth, pas d'endpoint "mes repas", pas de webhook). Toute intégration "live" est donc exclue.
### 2.2 Export de données utilisateur (in-app + RGPD)
Constats issus de plusieurs sources (guides Nutrola 2025-2026, politique de confidentialité Foodvisor) :
- **Export intégré** : dans l'app, `Réglages` → section `Compte` → entrée type « Demander mes données » / « Request my data » (l'intitulé et l'emplacement exacts varient selon les versions ; sur le tableau de bord web l'option est sous `Account``Privacy``Data`). L'export est **envoyé par e-mail**, généralement sous 24-72 h (le SLA de la politique de confidentialité autorise jusqu'à 30 jours).
- **Contenu de l'export** (archive ZIP contenant plusieurs CSV, ou CSV/PDF selon les versions) :
- objectifs et réglages en **CSV clé-valeur "à plat"** (objectif calorique, répartition des macros, métriques corporelles, niveau d'activité) ;
- **journal alimentaire** : une ligne par entrée avec horodatage, créneau de repas (petit-déjeuner/déjeuner/dîner/collation), nom de l'aliment, portion, macros calculées (protéines, glucides, lipides) et calories ;
- **totaux quotidiens** calories + macronutriments (parfois limités à une période récente et non à l'historique complet) ;
- **historique de poids** saisi manuellement (date + valeur, une ligne par pesée) ;
- aliments/recettes personnalisés avec leurs macros mais **rarement** la décomposition en ingrédients.
- **Ce qui manque** (confirmé par les guides de migration) : photos et scores de confiance IA, détails des scans code-barres (codes EAN), micronutriments au-delà des macros, horodatages fins au-delà du jour pour les totaux, notes/noms personnalisés, résumés de coaching.
- **Pièges de format observés** : horodatages « généralement ISO 8601 mais parfois en heure locale sans offset » ; encodage UTF-8 (Windows-1252 sur d'anciens exports) ; en-têtes potentiellement en français ou en anglais selon la langue du compte. **L'importeur devra être tolérant** (détection d'encodage, de délimiteur `,`/`;`, et mapping d'en-têtes bilingue).
- **Demande RGPD (article 15, droit d'accès + article 20, portabilité)** : Foodvisor est une société française ; contact **`data@foodvisor.io`** (contact officiel indiqué dans la politique de confidentialité pour l'exercice des droits d'accès, rectification, effacement, portabilité). Délai légal : **1 mois**, prolongeable de 2 mois pour les demandes complexes. Demander explicitement « l'intégralité de mon journal alimentaire au format structuré lisible par machine (CSV ou JSON), incluant les horodatages, portions et valeurs nutritionnelles » — cette voie contourne les limitations du bouton d'export intégré.
**Conséquence pour LifeTrack** : l'import Foodvisor est un **import ponctuel de rattrapage d'historique** (one-shot ou trimestriel), pas un flux continu. L'importeur CSV Foodvisor doit être conçu comme un *profil de mapping* du framework d'import générique (section 7.3), avec un écran de prévisualisation/mapping de colonnes car le format exact n'est **pas documenté officiellement et peut changer**. Ne pas coder en dur des noms de colonnes : livrer un mapping par défaut + éditeur de mapping.
### 2.3 Foodvisor Vision API (produit développeur)
- Produit B2B de Foodvisor : détection d'aliments et calcul nutritionnel à partir d'une photo (items reconnus, calories, macros, estimation des portions). Page produit : `https://www.foodvisor.io/en/vision/` ; documentation : `https://vision.foodvisor.io/docs` (⚠️ lors de nos tests d'août 2026, ce sous-domaine **ne résout plus en DNS** — signe que le produit est peu maintenu ou réservé aux clients sous contrat).
- **Modèle d'accès : contrat commercial entreprise uniquement.** Pas d'inscription self-service, pas de tarif public, pas de free tier, pas de spécification OpenAPI publique ; « les détails d'endpoints et d'authentification sont partagés avec les clients » sous accord. Accès via le formulaire de contact commercial.
- **Verdict** : inutilisable pour un projet personnel auto-hébergé en v1. Si l'analyse de photos de repas devient une exigence, alternatives à évaluer plus tard (non recherchées en détail ici, à vérifier avant usage) : LogMeal API, Passio Nutrition-AI, ou un LLM multimodal généraliste. **Décision d'architecture** : définir côté backend une interface `PhotoAnalyzer` (entrée : image ; sortie : liste de `FoodItemCandidate` avec macros et confiance) pour brancher n'importe quel fournisseur plus tard, y compris Foodvisor Vision si un contrat devenait accessible.
### 2.4 Foodvisor → Health Connect ?
- Sur **iOS**, Foodvisor se synchronise avec **Apple Health** (non pertinent ici, utilisateur Android).
- Sur **Android**, la seule intégration documentée est **Google Fit** (import/export nutrition, exercice, poids vers l'app Google Fit ; des fils de support Google Fit confirment cette synchro et ses ratés). **Aucune mention publique de support Health Connect** par Foodvisor à date (recherches août 2026).
- ⚠️ Les **API Google Fit sont dépréciées** (annonce Google mai 2024, arrêt programmé, migration officielle vers Health Connect). Le chemin `Foodvisor → Google Fit` est donc fragile et sans avenir.
- **Chemin indirect à tester** (effort ~30 min, aucune dépendance de code) : l'app tierce **Health Sync** (`nl.appyhapps.healthsync`, payante ~3-4 €/an) sait recopier des données entre Google Fit et Health Connect pour certains types de données. À tester sur le téléphone de l'utilisateur : si les repas Foodvisor poussés dans Google Fit peuvent être recopiés en `NutritionRecord` Health Connect, alors le bridge Health Connect de LifeTrack (section 3) récupérera les données Foodvisor **sans aucun code spécifique Foodvisor**. Ne pas bloquer la v1 sur ce test ; c'est un bonus.
---
## 3. Health Connect — type de données Nutrition (chemin cible)
### 3.1 Principe et contrainte fondamentale
Health Connect (Android 14+ intégré ; app système) est un magasin de données santé **sur l'appareil uniquement : il n'existe AUCUNE API cloud/REST côté serveur**. Pour amener les données dans LifeTrack, il faut une app Android (le « bridge » déjà prévu au projet) qui lit Health Connect via `androidx.health.connect:connect-client` et pousse vers l'endpoint REST authentifié de LifeTrack.
### 3.2 `NutritionRecord` — champs exacts
Classe : `androidx.health.connect.client.records.NutritionRecord` (miroir plateforme : `android.health.connect.datatypes.NutritionRecord`). Un enregistrement = un repas/une prise alimentaire sur un intervalle de temps.
- **Temporalité** : `startTime`, `endTime` (Instant, UTC), `startZoneOffset`, `endZoneOffset` (nullable).
- **Identité** : `name` (String?, nom du repas/aliment), `mealType` (Int) avec valeurs : `MEAL_TYPE_UNKNOWN = 0`, `MEAL_TYPE_BREAKFAST = 1`, `MEAL_TYPE_LUNCH = 2`, `MEAL_TYPE_DINNER = 3`, `MEAL_TYPE_SNACK = 4`.
- **Énergie** : `energy`, `energyFromFat` — type `Energy` (exposer en kcal via `energy.inKilocalories`).
- **Nutriments** (tous de type `Mass`, à lire en grammes via `inGrams` ; tous nullables) : `protein`, `totalCarbohydrate`, `sugar`, `dietaryFiber`, `totalFat`, `saturatedFat`, `unsaturatedFat`, `monounsaturatedFat`, `polyunsaturatedFat`, `transFat`, `cholesterol`, `sodium`, `potassium`, `calcium`, `iron`, `magnesium`, `phosphorus`, `zinc`, `copper`, `manganese`, `selenium`, `iodine`, `chromium`, `molybdenum`, `chloride`, `caffeine`, `biotin`, `folate`, `folicAcid`, `niacin`, `pantothenicAcid`, `riboflavin`, `thiamin`, `vitaminA`, `vitaminB6`, `vitaminB12`, `vitaminC`, `vitaminD`, `vitaminE`, `vitaminK`.
- ⚠️ Les documentations Google confirment : **tous les nutriments sont en grammes** (y compris sodium — pas en mg — et les vitamines), l'énergie en kcal. Convertir côté bridge ou côté API, mais **stocker en unités SI cohérentes** (voir 7.2).
- **Métadonnées** (`metadata`) : `id` (UUID Health Connect, **clé de déduplication idéale**), `dataOrigin.packageName` (app source, ex. `com.myfitnesspal.android`, permet le champ `source_app`), `lastModifiedTime`, `clientRecordId`/`clientRecordVersion`, `recordingMethod`, `device`.
- **Agrégats disponibles** (API `aggregate`) : `NutritionRecord.ENERGY_TOTAL`, `PROTEIN_TOTAL`, `TOTAL_CARBOHYDRATE_TOTAL`, `TOTAL_FAT_TOTAL`, etc. — utile si le bridge veut pousser des totaux journaliers en plus des repas unitaires. Recommandation : **pousser les enregistrements unitaires** et laisser LifeTrack agréger.
### 3.3 Permissions et limites côté bridge
- Permission de lecture : `android.permission.health.READ_NUTRITION` (+ autres types déjà prévus : steps, calories, distance, poids, exercices).
- **Limite des 30 jours** : par défaut une app ne peut lire que les données antérieures de 30 jours max à la première autorisation. La permission `android.permission.health.READ_HEALTH_DATA_HISTORY` (introduite en 2024) lève cette limite — **à demander dès la v1 du bridge** pour récupérer l'historique complet.
- Lecture incrémentale : utiliser l'API **Changes / changement de jetons** (`getChangesToken` + `getChanges`) pour ne pousser que les deltas ; repli : requête par `TimeRangeFilter` depuis le dernier push réussi.
- Lecture en arrière-plan : permission `READ_HEALTH_DATA_IN_BACKGROUND` + WorkManager périodique (les bridges existants utilisent des périodes de 1-2 h).
### 3.4 Bridges open source réutilisables (au lieu de tout écrire)
- **HCGateway** (`github.com/ShuchirJ/HCGateway`) : bridge REST universel Health Connect ↔ serveur, app Android + serveur auto-hébergeable, sync bidirectionnelle, push toutes les ~2 h. Projet jeune (« API may change without notice ») mais **la partie app Android est une excellente base de code de référence** (lecture de tous les types de records dont nutrition, sérialisation JSON).
- **health-connect-webhook** (`github.com/mcnaveen/health-connect-webhook`) : app Android qui pousse les données Health Connect vers un webhook arbitraire — modèle exactement aligné avec notre endpoint d'ingestion générique.
- **Health Sync** (payant, closed source) : pour la recopie inter-apps sur le téléphone (cf. 2.4), pas pour pousser vers LifeTrack.
**Recommandation** : concevoir l'endpoint d'ingestion LifeTrack (section 7.4) de façon à pouvoir accepter soit notre futur bridge maison, soit un HCGateway/webhook adapté, en gardant un schéma JSON proche du modèle `NutritionRecord`.
---
## 4. Open Food Facts (OFF) — recherche produit + code-barres
Base collaborative mondiale (>3 M produits, très bonne couverture des produits vendus en France). **Gratuit, sans clé d'API.**
### 4.1 Endpoints
- **Produit par code-barres** :
`GET https://world.openfoodfacts.org/api/v2/product/{barcode}` (JSON). Utiliser `https://fr.openfoodfacts.org/...` pour prioriser les libellés français.
Paramètre `fields` pour limiter la réponse, ex. :
`?fields=code,product_name,product_name_fr,brands,quantity,serving_size,serving_quantity,nutriments,nutriscore_grade,nova_group,ecoscore_grade,image_front_small_url,categories_tags,lang`
- **API v3** (courante, recommandée pour les nouvelles intégrations) : `GET /api/v3/product/{barcode}` — v2 reste supportée ; v0/v1 à éviter.
- **Recherche plein texte** : ⚠️ pas disponible dans l'API v2 serveur. Deux options :
- **Search-a-licious** (service de recherche officiel) : `https://search.openfoodfacts.org` (API documentée sur ce domaine, `GET /search?q=...&langs=fr&page_size=20`) — recommandé ;
- legacy : `GET https://fr.openfoodfacts.org/cgi/search.pl?search_terms=...&json=1` (lent, déprécié mais fonctionnel).
- **Recherche filtrée v2** (par catégorie/marque/nutriment, sans plein texte) : `GET /api/v2/search?categories_tags=...&fields=...`.
- **Environnement de staging** : `https://world.openfoodfacts.net` (HTTP Basic `off`/`off`) — utiliser pour les tests d'intégration automatisés.
- **SDK Python officiel** : paquet PyPI `openfoodfacts` (`openfoodfacts-python`) — utilisable directement dans l'API FastAPI, mais un simple client `httpx` suffit.
### 4.2 Champs `nutriments` utiles (clés JSON exactes)
Par 100 g : `energy-kcal_100g`, `energy_100g` (kJ), `proteins_100g`, `carbohydrates_100g`, `sugars_100g`, `fat_100g`, `saturated-fat_100g`, `fiber_100g`, `salt_100g`, `sodium_100g` (en g). Variantes par portion : suffixe `_serving` (ex. `energy-kcal_serving`) + `serving_size` (texte, ex. "30 g") et `serving_quantity` (nombre). Les champs peuvent être **absents ou incohérents** (données collaboratives) : toujours valider (`energy-kcal_100g` manquant → recalculer depuis kJ ÷ 4,184 ; contrôler que macros × facteurs Atwater ≈ énergie à ±20 %).
### 4.3 Règles d'usage impératives
- **User-Agent personnalisé obligatoire** : format `AppName/Version (ContactEmail)`, ex. `LifeTrack/1.0 (meejayproduction@gmail.com)` — sous peine d'être traité comme bot.
- **Rate limits (docs officielles, août 2026)** : **15 req/min/IP** pour les lectures produit, **10 req/min/IP** pour les recherches. Conséquences d'implémentation : (a) toutes les requêtes OFF passent par le **backend LifeTrack (proxy)**, jamais depuis le navigateur ; (b) **cache local** de chaque produit consulté (table `food_items`, cf. 7.2) — un produit scanné une fois ne re-sollicite plus OFF ; (c) throttle côté serveur (token bucket 10/min) + retry avec backoff sur 429.
- **Pas de scraping massif via l'API** : pour un besoin hors-ligne complet, utiliser les **exports intégraux** (CSV ~ plusieurs Go, JSONL, dump MongoDB, Parquet sur Hugging Face) — non nécessaire en v1.
- **Licence** : base sous **ODbL** (contenus sous DbCL, images CC-BY-SA). Pour un usage personnel auto-hébergé : afficher une attribution « Données produits : Open Food Facts (ODbL) » dans l'UI suffit largement ; l'obligation de partage à l'identique ne s'applique que si l'on redistribue une base dérivée.
---
## 5. CIQUAL (ANSES) — table de composition française (aliments génériques)
Complément indispensable d'OFF : OFF couvre les **produits industriels code-barrés**, CIQUAL couvre les **aliments génériques** (« Pomme, crue », « Baguette courante », « Poulet rôti, cuisse ») — exactement ce qu'il faut pour la saisie manuelle de plats maison.
- **Éditeur** : ANSES (Agence nationale de sécurité sanitaire de l'alimentation). Site de consultation : `https://ciqual.anses.fr`.
- **Versions** : table **Ciqual 2020** (~3 185 aliments, ~60 constituants) ; une **table Ciqual 2025** a été publiée fin 2025 (~3 484 aliments, sucres individuels détaillés, fruits/légumes mis à jour incl. outre-mer). Prendre la 2025 si disponible au moment de l'implémentation, sinon 2020.
- **Téléchargement** : formats **Excel (.xls/.xlsx) et XML**, depuis `ciqual.anses.fr` (rubrique téléchargement) et sur **data.gouv.fr** : jeu de données « Table de composition nutritionnelle des aliments Ciqual » (`https://www.data.gouv.fr/datasets/table-de-composition-nutritionnelle-des-aliments-ciqual-2020`). La table 2025 est aussi déposée sur `entrepot.recherche.data.gouv.fr` (DOI `10.57745/RDMHWY`). Documentation PDF officielle du format Excel disponible sur le site Ciqual.
- **Licence** : open data (Licence Ouverte / Etalab 2.0 via data.gouv.fr) — usage libre avec mention de la source « ANSES — Table Ciqual ».
- **Structure du fichier Excel (à connaître pour l'importeur)** :
- une ligne par aliment : `alim_code` (code numérique stable), `alim_nom_fr` (libellé français), `alim_nom_eng`, groupes/sous-groupes (`alim_grp_code`, `alim_grp_nom_fr`, `alim_ssgrp_...`) ;
- une colonne par constituant, valeurs **pour 100 g**, libellés du type « Energie, Règlement UE N° 1169/2011 (kcal/100 g) », « Protéines, N x facteur de Jones (g/100 g) », « Glucides (g/100 g) », « Lipides (g/100 g) », « Sucres (g/100 g) », « Fibres alimentaires (g/100 g) », « Sel chlorure de sodium (g/100 g) », vitamines/minéraux ;
- **pièges de parsing** : séparateur décimal **virgule** ; valeurs non numériques `"-"` (non déterminé), `"traces"`, et bornes `"< 0,5"` — l'importeur doit normaliser (`traces` → 0, `< x` → x/2 ou 0 selon règle choisie, `-` → NULL) ; encodage à vérifier (exports historiques en Windows-1252/latin-1).
- **Intégration recommandée** : script one-shot `scripts/import_ciqual.py` qui charge le fichier Excel/XML dans la table `food_items` avec `source='ciqual'`, `source_id=alim_code`. Le fichier CIQUAL est mis dans un volume/dossier `data/` (préchargé dans l'image ou téléchargé au premier lancement par l'admin) — pas d'appel réseau au runtime.
---
## 6. Exports CSV des autres apps de suivi alimentaire
Objectif : le framework d'import CSV doit accepter les exports des apps majeures, pour migration d'historique ou si l'utilisateur change d'app un jour.
### 6.1 MyFitnessPal
- **Export "File Export" (Premium uniquement)** : Web → `Reports`/`Settings` → export par plage de dates ; production asynchrone (minutes à heures), lien envoyé par e-mail ; **3 CSV : Nutrition, Exercise, Progress**. Le CSV Nutrition est à la granularité **jour × repas** (Breakfast/Lunch/Dinner/Snacks), avec colonnes type : `Date`, `Meal`, `Calories`, `Fat (g)`, `Saturated Fat`, `Polyunsaturated Fat`, `Monounsaturated Fat`, `Trans Fat`, `Cholesterol`, `Sodium (mg)`, `Potassium`, `Carbohydrates (g)`, `Fiber`, `Sugar`, `Protein (g)`, `Vitamin A`, `Vitamin C`, `Calcium`, `Iron`, `Note` (⚠️ liste constatée sur des exports réels et outils communautaires, non documentée officiellement — **valider sur un échantillon réel** avant de figer le mapping ; d'où l'éditeur de mapping, 7.3).
- **Compte gratuit** : demande de téléchargement des données personnelles (RGPD/CCPA) via les réglages du compte — archive moins structurée.
- Il existe une lib Python non officielle `python-myfitnesspal` (scraping avec login) — fragile, **hors périmètre v1**.
### 6.2 Cronometer
- Export self-service (Web → `More`/`Account``Export Data`, plage de dates), **immédiat et gratuit**, en 5 fichiers CSV : `servings.csv` (une ligne par aliment consommé : `Day`, `Group` (repas), `Food Name`, `Amount`, puis ~70 colonnes de nutriments avec unités dans l'en-tête, ex. `Energy (kcal)`, `Protein (g)`, `Sodium (mg)`), `dailysummary.csv` (totaux/jour), `exercises.csv`, `biometrics.csv` (poids !), `notes.csv`. Le plus propre et le plus riche des trois — profil de mapping facile.
### 6.3 Yazio
- **Pas d'export CSV natif** : l'app ne propose qu'un PDF limité ; l'archive complète s'obtient par **demande RGPD** (siège à Erfurt, Allemagne ; réponse sous 1 mois ; en pratique ZIP de JSON/CSV).
- Outils open source non officiels : `github.com/aleksandr-bogdanov/yazio-exporter` (Python ; login e-mail/mot de passe sur l'API non officielle de Yazio, export JSON/CSV/SQLite : journal, aliments consommés, poids, exercices, eau, 40+ micronutriments) et `github.com/tobintax/yazio-csv-exporter`. Utilisables ponctuellement par l'utilisateur **en dehors** de LifeTrack ; LifeTrack se contente d'importer les CSV produits.
### 6.4 Foodvisor (rappel)
Voir 2.2 : ZIP de CSV (journal par entrée, totaux quotidiens, poids, réglages clé-valeur). Profil d'import dédié, tolérant (encodage, délimiteur, en-têtes FR/EN, horodatages sans offset → interpréter en Europe/Paris puis convertir en UTC).
---
## 7. Stratégie d'ingestion nutrition v1 — recommandations concrètes
### 7.1 Les quatre canaux v1 (ordre de priorité d'implémentation)
1. **Saisie manuelle rapide** (UI français) : formulaire "repas" minimal — date/heure (défaut : maintenant, TZ Europe/Paris), type de repas (petit-déjeuner/déjeuner/dîner/collation), soit saisie libre kcal+macros, soit sélection d'un aliment (recherche locale `food_items` + OFF + CIQUAL) × quantité. Duplication d'un repas précédent ("répéter hier") = fonctionnalité à fort ROI.
2. **Recherche produit** : endpoint backend `GET /api/v1/foods/search?q=...` qui interroge (a) le cache local `food_items`, (b) CIQUAL local, (c) Open Food Facts (Search-a-licious + fallback code-barres direct), fusionne et met en cache. Scan de code-barres possible plus tard côté PWA (caméra) → `GET /api/v1/foods/barcode/{ean}`.
3. **Importeurs CSV** via le framework d'import générique du projet : profils `foodvisor`, `myfitnesspal`, `cronometer` (+ `generic_nutrition_csv` avec mapping manuel). Pipeline : upload → détection encodage/délimiteur → proposition de mapping (profil) → prévisualisation → validation → insertion idempotente.
4. **Endpoint d'ingestion REST** pour le bridge Health Connect (voir 7.4) — même endpoint générique que pour les pas/poids, avec le type `nutrition`.
### 7.2 Modèle de données proposé (SQLAlchemy, PostgreSQL)
Deux tables, séparant **référentiel d'aliments** et **journal** :
```text
food_items # referential/cache of foods
id UUID PK
source TEXT NOT NULL -- 'off' | 'ciqual' | 'custom'
source_id TEXT -- OFF barcode | ciqual alim_code | NULL
name TEXT NOT NULL -- French label preferred
brand TEXT
energy_kcal_100g NUMERIC
protein_g_100g NUMERIC
carbs_g_100g NUMERIC
sugar_g_100g NUMERIC
fat_g_100g NUMERIC
sat_fat_g_100g NUMERIC
fiber_g_100g NUMERIC
salt_g_100g NUMERIC -- salt = sodium * 2.5
serving_size_g NUMERIC
raw_payload JSONB -- full OFF/CIQUAL record
UNIQUE (source, source_id)
nutrition_entries # the journal (one row = one food eaten OR one whole meal)
id UUID PK
user_id UUID FK
eaten_at TIMESTAMPTZ NOT NULL -- stored UTC
meal_type TEXT NOT NULL -- 'breakfast'|'lunch'|'dinner'|'snack'|'unknown'
label TEXT -- free-text name
food_item_id UUID FK NULL -- when picked from referential
quantity_g NUMERIC NULL
energy_kcal NUMERIC NOT NULL -- always denormalized at entry level
protein_g NUMERIC
carbs_g NUMERIC
sugar_g NUMERIC
fat_g NUMERIC
sat_fat_g NUMERIC
fiber_g NUMERIC
sodium_mg NUMERIC
source TEXT NOT NULL -- 'manual'|'import:foodvisor'|'import:mfp'|'import:cronometer'|'healthconnect'
source_record_id TEXT -- HC metadata.id, or import row hash
source_app TEXT -- HC dataOrigin.packageName
import_batch_id UUID FK NULL
UNIQUE (user_id, source, source_record_id)
```
- Unités stockées : **kcal, grammes, sodium en mg** (convention affichage FR) — conversions faites à l'ingestion (Health Connect fournit tout en g/kcal, cf. 3.2).
- Le bilan énergétique quotidien (kcal in) = `SUM(energy_kcal)` groupé par jour **en Europe/Paris** (conversion au moment de la requête, pas au stockage).
- **Déduplication inter-sources** : contrainte unique `(user_id, source, source_record_id)` pour l'idempotence intra-source ; pour l'inter-sources (ex. un repas présent à la fois dans le CSV Foodvisor et via Health Connect), règle v1 simple : l'UI signale les jours où deux sources se chevauchent (>1 source sur la même journée) et propose de masquer une source par plage de dates (`source_priority` par jour). Ne pas tenter de matching flou par entrée en v1.
### 7.3 Framework d'import CSV — exigences pour la nutrition
- Détection : BOM/UTF-8/Windows-1252, délimiteur `,`/`;`/tab, format de date (`YYYY-MM-DD`, `DD/MM/YYYY`, ISO 8601 avec/sans offset — sans offset ⇒ supposer Europe/Paris).
- Profils = fichiers de mapping déclaratifs (JSON/YAML versionnés dans le repo) : `column → field`, transformations (`comma_decimal`, `meal_type_map` FR/EN : `Petit-déjeuner→breakfast`, etc.), granularité (`per_food_row` pour Cronometer `servings.csv`, `per_meal_row` pour MFP, `per_entry_row` pour Foodvisor).
- Idempotence : `source_record_id = SHA-256(user_id + source + date + meal + label + kcal)` en absence d'ID natif ; ré-import du même fichier = 0 doublon.
- Chaque import crée un `import_batch` (annulable en bloc — indispensable vu l'instabilité des formats).
### 7.4 Endpoint d'ingestion Health Connect (bridge)
`POST /api/v1/ingest/nutrition` (JWT device token, scope `ingest`) — schéma aligné sur `NutritionRecord` :
```json
{
"records": [
{
"source_record_id": "hc-uuid-from-metadata-id",
"source_app": "io.foodvisor.foodvisor",
"start_time": "2026-08-13T11:45:00Z",
"end_time": "2026-08-13T12:15:00Z",
"meal_type": "lunch",
"name": "Salade poulet",
"energy_kcal": 620.0,
"protein_g": 42.1,
"total_carbohydrate_g": 51.0,
"sugar_g": 8.2,
"total_fat_g": 24.3,
"saturated_fat_g": 6.1,
"dietary_fiber_g": 7.0,
"sodium_g": 1.1
}
]
}
```
Réponse : `{"accepted": n, "duplicates": m, "rejected": [...]}` — upsert sur `(user_id, 'healthconnect', source_record_id)`. Le même pattern (`/ingest/steps`, `/ingest/weight`, ...) sert tout le bridge Health Connect. Côté bridge : demander `READ_NUTRITION` + `READ_HEALTH_DATA_HISTORY`, sync incrémentale par Changes API, batch de 100-500 records.
### 7.5 Garder un futur connecteur Foodvisor possible
1. **Architecture connecteurs** : chaque source = classe `Connector` (métadonnées, capacités `file_import`/`api_pull`/`push`, mapping). `FoodvisorCsvConnector` (v1, `file_import`) et un éventuel `FoodvisorApiConnector` (si une API apparaît) partagent le même normaliseur `FoodvisorRecordNormalizer`.
2. **Interface `PhotoAnalyzer`** (cf. 2.3) : port d'extension pour Foodvisor Vision ou tout autre service d'analyse de photo.
3. **Chemin sans code** : si le test Health Sync (2.4) réussit, les données Foodvisor arrivent déjà par le canal Health Connect avec `source_app` identifiable.
4. **Veille** : re-vérifier périodiquement (a) l'apparition d'un support Health Connect dans l'app Foodvisor Android (fiche Play Store, permissions `android.permission.health.WRITE_NUTRITION`), (b) la résurrection de `vision.foodvisor.io`.
---
## 8. Checklist pour les agents d'implémentation
- [ ] Table `food_items` + import CIQUAL (script one-shot, gestion `traces`/`< x`/virgule décimale).
- [ ] Proxy OFF : client httpx avec `User-Agent: LifeTrack/1.0 (meejayproduction@gmail.com)`, cache DB, throttle 10 req/min, staging `.net` pour les tests.
- [ ] `GET /foods/search`, `GET /foods/barcode/{ean}` ; UI de saisie manuelle FR (types de repas : Petit-déjeuner, Déjeuner, Dîner, Collation).
- [ ] Framework import CSV + profils `cronometer` (le plus simple, commencer par lui), `myfitnesspal`, `foodvisor`, `generic_nutrition_csv` ; éditeur de mapping ; batches annulables.
- [ ] `POST /api/v1/ingest/nutrition` idempotent (+ doc French pour configurer HCGateway/webhook en attendant le bridge maison).
- [ ] Attributions UI : « Open Food Facts (ODbL) », « ANSES — Table Ciqual ».
- [ ] Doc utilisateur FR : procédure d'export Foodvisor in-app + modèle d'e-mail RGPD à `data@foodvisor.io`.
## 9. Sources
- Guide export Foodvisor (FR) : https://nutrola.app/fr/blog/how-to-export-data-from-foodvisor ; migration : https://nutrola.app/en/blog/migrating-from-foodvisor-how-to-import-data
- Politique de confidentialité Foodvisor : https://www.foodvisor.io/en/privacy-policy/ ; support privacy : https://foodvisor.zendesk.com/hc/en-us/categories/360002566060-Privacy
- Foodvisor Vision API : https://www.foodvisor.io/en/vision/ ; https://vision.foodvisor.io/docs (DNS KO août 2026) ; https://github.com/api-evangelist/foodvisor ; https://apis.io/providers/foodvisor/
- Foodvisor ↔ Google Fit (fil support) : https://support.google.com/fit/thread/327566968
- Health Connect `NutritionRecord` : https://developer.android.com/reference/androidx/health/connect/client/records/NutritionRecord ; https://developer.android.com/reference/android/health/connect/datatypes/NutritionRecord ; nutrition data types : https://developers.google.com/health/data-types/nutrition ; écriture : https://developer.android.com/health-and-fitness/health-connect/write-data
- Bridges : https://github.com/ShuchirJ/HCGateway ; https://github.com/mcnaveen/health-connect-webhook ; https://healthsync.app/
- Open Food Facts API : https://openfoodfacts.github.io/openfoodfacts-server/api/ ; recherche : https://search.openfoodfacts.org
- CIQUAL : https://ciqual.anses.fr ; https://www.data.gouv.fr/datasets/table-de-composition-nutritionnelle-des-aliments-ciqual-2020 ; doc table 2025 : https://ciqual.anses.fr/cms/sites/default/files/inline-files/Table%20Ciqual%202025%20doc%20FR_2025_11_19.pdf ; dépôt 2025 : https://entrepot.recherche.data.gouv.fr/dataset.xhtml?persistentId=doi:10.57745/RDMHWY
- MyFitnessPal export : https://support.myfitnesspal.com/hc/en-us/articles/360032273352-Data-Export-FAQs ; https://quantifiedself.com/blog/access-export-myfitnesspal-data/
- Cronometer export : https://support.cronometer.com/hc/en-us/articles/360018760151-Account-Settings ; forums : https://forums.cronometer.com/discussion/460/exporting-data
- Yazio : https://nutrola.app/en/blog/how-to-export-data-from-yazio ; https://github.com/aleksandr-bogdanov/yazio-exporter ; https://github.com/tobintax/yazio-csv-exporter