Files
lifetrack/docs/design/datamodel-health-vape.md
MeeJayandClaude Opus 5 93f0689c1e 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>
2026-08-14 10:48:57 +02:00

1147 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LifeTrack — Modèle de données & calculs : modules SANTÉ / FITNESS / NUTRITION et VAPE
> **Statut** : spécification de conception, prête pour implémentation.
> **Portée** : tables SQL (types SQLAlchemy 2.0 / PostgreSQL 16), contraintes, index, stratégies de fusion/déduplication, formules de calcul exactes avec pseudocode, et endpoints API.
> **Hors portée** : module FINANCE, framework de connecteurs/importeurs (documents séparés) — seuls les points de contact sont mentionnés ici.
> **Langue** : prose en français, identifiants de code en anglais (conforme aux conventions projet).
---
## 1. Conventions transverses
Ces conventions s'appliquent à **toutes** les tables décrites dans ce document. Les agents d'implémentation doivent les respecter sans exception.
### 1.1 Multi-utilisateur
- Toutes les tables de données portent une colonne `user_id` : `ForeignKey("users.id", ondelete="CASCADE")`, `nullable=False`.
- La table `users` (id `BigInteger` identity PK, email, password_hash, created_at…) est définie dans le document d'architecture auth ; ici on la considère acquise.
- **Tous les index composites commencent par `user_id`** afin que chaque requête filtrée par utilisateur soit couverte.
- Aucune donnée n'est jamais lue sans filtre `user_id == current_user.id` (imposé au niveau des repositories/services).
### 1.2 Clés primaires et horodatage
- PK : `id = mapped_column(BigInteger, Identity(), primary_key=True)` sur toutes les tables (pas d'UUID : app mono-instance, IDs séquentiels suffisants et plus compacts pour les index).
- Colonnes d'audit sur toutes les tables :
- `created_at : DateTime(timezone=True), server_default=func.now(), nullable=False`
- `updated_at : DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False`
- Ces colonnes ne sont **pas répétées** dans les tableaux ci-dessous pour alléger la lecture, mais elles sont obligatoires.
### 1.3 Temps et fuseaux horaires
Règle projet : **stockage UTC, affichage Europe/Paris**.
- Tout instant précis : `DateTime(timezone=True)` (⇒ `timestamptz`), stocké en UTC.
- Les colonnes de type `Date` (`activity_daily.date`, `liquid_entries.entry_date`, etc.) représentent un **jour civil local** (fuseau du profil utilisateur, défaut `Europe/Paris`). C'est une décision volontaire : « les pas du 12 août » sont un concept local, pas UTC.
- **Convention d'agrégation journalière** : pour agréger des `timestamptz` par jour (repas, pesées, recharges), on convertit d'abord en local : `date_local = (ts AT TIME ZONE 'UTC') AT TIME ZONE user.timezone` puis `::date`. En Python : `ts.astimezone(user_tz).date()`. Toutes les séries « par jour » de ce document utilisent cette convention.
### 1.4 Types numériques
- Mesures corporelles, volumes, prix : `Numeric(p, s)` (jamais `Float` pour l'argent ni les poids).
- Compteurs entiers (pas, ml arrondis non — ml sont décimaux) : `Integer` / `BigInteger`.
- Calories : `Numeric(7, 1)` (permet les décimales des exports Foodvisor tout en restant compact).
### 1.5 Provenance des données (`source`) et déduplication
Le framework de connecteurs doit pouvoir ajouter des sources sans migration SQL. La colonne `source` est donc un `String(32)` **contrôlé côté application** (registre Python), et non un enum PostgreSQL.
Valeurs initiales du registre `DataSource` (module `app/core/sources.py`) :
```python
class DataSource(str, enum.Enum):
MANUAL = "manual" # saisie UI
CSV_IMPORT = "csv_import" # import fichier générique
HEALTH_CONNECT = "health_connect" # push app compagnon Android
FOODVISOR = "foodvisor" # export Foodvisor
FITSHOW = "fitshow" # export/synchro FitShow (tapis)
API = "api" # ingestion REST générique
```
**Clé de déduplication standard** : toute table alimentée par des connecteurs porte :
- `external_id : String(128), nullable=True` — identifiant chez la source (UUID Health Connect, id de ligne d'export, hash de ligne CSV…). Pour les imports CSV sans identifiant natif, l'importeur calcule `external_id = sha256(ligne_normalisée)[:32]`.
- Contrainte : **index unique partiel** (les `UniqueConstraint` classiques laissent passer les doublons de saisie manuelle où `external_id IS NULL`) :
```python
Index(
"uq_<table>_user_source_extid",
"user_id", "source", "external_id",
unique=True,
postgresql_where=text("external_id IS NOT NULL"),
)
```
- Comportement des importeurs : `INSERT ... ON CONFLICT (user_id, source, external_id) DO UPDATE` (upsert idempotent) ⇒ ré-importer le même fichier ne crée jamais de doublon.
- `raw : JSONB, nullable=True` — charge utile brute de la source (payload Health Connect, ligne CSV parsée…). Conservée pour ré-interprétation future ; jamais utilisée dans les calculs.
### 1.6 Enums PostgreSQL natifs
Utilisés uniquement pour les domaines **stables** (une migration Alembic par ajout de valeur est acceptable) : `sex`, `activity_level`, `meal_type`, `goal_mode`, `goal_status`, `sport_type`, `product_kind`, `size_unit`, `liquid_entry_kind`. Déclaration SQLAlchemy : `Enum(PyEnum, name="<snake_name>", native_enum=True)`.
### 1.7 Suppression
Suppression **physique** (hard delete) pour toutes les tables de ce document — pas de soft delete. Exceptions : `products` et `mixes` utilisent un drapeau `is_archived` car ils sont référencés par l'historique (`purchases`, `coil_changes`, `liquid_entries`) ; leur suppression physique n'est autorisée que si aucune référence n'existe (`ondelete="RESTRICT"`).
---
## 2. Vue d'ensemble (diagramme ER)
```mermaid
erDiagram
users ||--|| user_profile : "1-1"
users ||--o{ weight_entries : ""
users ||--o{ body_measurements : ""
users ||--o{ activity_daily : ""
users ||--o{ workouts : ""
users ||--o{ goals : ""
users ||--o{ food_entries : ""
users ||--o{ food_favorites : ""
users ||--o{ water_entries : ""
users ||--|| vape_settings : "1-1"
users ||--o{ liquid_entries : ""
users ||--o{ products : ""
users ||--o{ mixes : ""
users ||--o{ coil_changes : ""
users ||--o{ purchases : ""
mixes ||--o{ mix_components : ""
products ||--o{ mix_components : ""
products ||--o{ coil_changes : ""
products ||--o{ purchases : ""
mixes ||--o{ liquid_entries : "mix utilisé"
```
---
## 3. Module SANTÉ / FITNESS
### 3.1 Table `user_profile`
Une ligne par utilisateur (relation 1-1 avec `users`). Contient les paramètres physiologiques nécessaires aux calculs BMR/TDEE.
| Colonne | Type SQLAlchemy | Null | Défaut | Description |
|---|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK |
| `user_id` | `BigInteger, ForeignKey("users.id", ondelete="CASCADE")` | non | — | Propriétaire |
| `height_cm` | `Numeric(4, 1)` | non | — | Taille en cm (ex. 178.5) |
| `sex` | `Enum(Sex, name="sex")` | non | — | `male` / `female` / `other` |
| `birthdate` | `Date` | non | — | Date de naissance (âge dérivé) |
| `activity_level` | `Enum(ActivityLevel, name="activity_level")` | non | `'sedentary'` | Niveau d'activité habituel |
| `timezone` | `String(64)` | non | `'Europe/Paris'` | Fuseau IANA de l'utilisateur |
| `water_goal_ml` | `Integer` | oui | `2000` | Objectif hydratation quotidien |
| `calorie_floor_kcal` | `Integer` | oui | `NULL` | Plancher calorique personnalisé (sinon défaut par sexe, cf. §5.3) |
Enums :
```python
class Sex(str, enum.Enum):
MALE = "male"
FEMALE = "female"
OTHER = "other" # formule BMR : moyenne homme/femme (cf. §5.1)
class ActivityLevel(str, enum.Enum):
SEDENTARY = "sedentary" # facteur 1.2
LIGHT = "light" # 1.375
MODERATE = "moderate" # 1.55
ACTIVE = "active" # 1.725
VERY_ACTIVE = "very_active" # 1.9
```
Contraintes / index :
- `UniqueConstraint("user_id", name="uq_user_profile_user")` — une seule ligne par utilisateur.
- `CheckConstraint("height_cm > 0 AND height_cm < 300", name="ck_user_profile_height")`.
### 3.2 Table `weight_entries`
Pesées. Plusieurs pesées par jour possibles (matin/soir) ; les calculs de tendance utilisent la **première pesée du jour local** (convention balance à jeun).
| Colonne | Type SQLAlchemy | Null | Défaut | Description |
|---|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | — | |
| `measured_at` | `DateTime(timezone=True)` | non | — | Instant de pesée (UTC) |
| `weight_kg` | `Numeric(5, 2)` | non | — | Poids en kg (ex. 92.40) |
| `body_fat_pct` | `Numeric(4, 1)` | oui | — | % masse grasse (balance impédancemètre) |
| `muscle_mass_kg` | `Numeric(5, 2)` | oui | — | Masse musculaire si fournie |
| `water_pct` | `Numeric(4, 1)` | oui | — | % eau si fourni |
| `source` | `String(32)` | non | `'manual'` | Registre `DataSource` |
| `external_id` | `String(128)` | oui | — | Dédup connecteurs (cf. §1.5) |
| `note` | `String(255)` | oui | — | Commentaire libre |
| `raw` | `JSONB` | oui | — | Payload source |
Contraintes / index :
- `Index("ix_weight_entries_user_measured", "user_id", "measured_at")` — requêtes de séries.
- Index unique partiel `uq_weight_entries_user_source_extid` (§1.5).
- `UniqueConstraint("user_id", "measured_at", "source", name="uq_weight_entries_user_ts_source")` — bloque le double-clic de saisie et les doubles imports sans external_id.
- `CheckConstraint("weight_kg > 20 AND weight_kg < 400", name="ck_weight_entries_range")`.
### 3.3 Table `body_measurements`
Mensurations. Une ligne = une séance de mesure ; toutes les colonnes de mesure sont optionnelles (l'utilisateur mesure ce qu'il veut).
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `measured_at` | `DateTime(timezone=True)` | non | Instant de mesure |
| `neck_cm` | `Numeric(4, 1)` | oui | Cou |
| `chest_cm` | `Numeric(4, 1)` | oui | Poitrine |
| `waist_cm` | `Numeric(4, 1)` | oui | Taille (nombril) — sert à la formule Navy |
| `hips_cm` | `Numeric(4, 1)` | oui | Hanches |
| `biceps_left_cm` | `Numeric(4, 1)` | oui | Biceps gauche |
| `biceps_right_cm` | `Numeric(4, 1)` | oui | Biceps droit |
| `thigh_left_cm` | `Numeric(4, 1)` | oui | Cuisse gauche |
| `thigh_right_cm` | `Numeric(4, 1)` | oui | Cuisse droite |
| `calf_left_cm` | `Numeric(4, 1)` | oui | Mollet gauche |
| `calf_right_cm` | `Numeric(4, 1)` | oui | Mollet droit |
| `source` | `String(32)` | non (déf. `'manual'`) | |
| `external_id` | `String(128)` | oui | Dédup |
| `note` | `String(255)` | oui | |
Contraintes / index :
- `Index("ix_body_measurements_user_measured", "user_id", "measured_at")`.
- Index unique partiel `uq_body_measurements_user_source_extid`.
**Bonus dérivé (formule US Navy, % masse grasse estimé)** — calculé à la volée si `waist_cm`, `neck_cm` (et `hips_cm` pour les femmes) sont présents, avec `h = height_cm` :
```
homme : bf% = 495 / (1.0324 0.19077·log10(waist neck) + 0.15456·log10(h)) 450
femme : bf% = 495 / (1.29579 0.35004·log10(waist + hips neck) + 0.22100·log10(h)) 450
```
### 3.4 Table `activity_daily`
Agrégats d'activité **par jour local et par source**. On stocke une ligne par `(user, date, source)` ; la fusion inter-sources est faite en lecture (cf. §3.5). Ne jamais écraser la ligne d'une source par une autre.
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `date` | `Date` | non | Jour civil local (Europe/Paris) |
| `steps` | `Integer` | oui | Nombre de pas |
| `active_kcal` | `Numeric(7, 1)` | oui | Calories actives (hors métabolisme de base) |
| `total_kcal` | `Numeric(7, 1)` | oui | Dépense totale du jour (TDEE mesuré) si la source la fournit |
| `distance_m` | `Integer` | oui | Distance en mètres |
| `active_minutes` | `Integer` | oui | Minutes actives si fournies |
| `floors` | `Integer` | oui | Étages si fournis |
| `source` | `String(32)` | non | Registre `DataSource` |
| `external_id` | `String(128)` | oui | Dédup (souvent inutile ici : la clé naturelle suffit) |
| `raw` | `JSONB` | oui | Payload source |
Contraintes / index :
- `UniqueConstraint("user_id", "date", "source", name="uq_activity_daily_user_date_source")`**clé de dédup naturelle** : un connecteur upserte sur ce triplet (`ON CONFLICT DO UPDATE` — Health Connect re-pousse le même jour plusieurs fois dans la journée avec des valeurs croissantes).
- `Index("ix_activity_daily_user_date", "user_id", "date")`.
- `CheckConstraint("steps IS NULL OR steps >= 0", name="ck_activity_daily_steps")`.
### 3.5 Fusion multi-sources de `activity_daily`
**Problème** : le même jour peut exister via `health_connect`, `fitshow`, `csv_import` et `manual`. **Stratégie : priorité par source, champ par champ** (pas ligne par ligne — une source peut fournir les pas, une autre les calories).
Ordre de priorité par défaut (du plus prioritaire au moins prioritaire), constante `ACTIVITY_SOURCE_PRIORITY` dans `app/health/merge.py` :
```python
ACTIVITY_SOURCE_PRIORITY = [
"manual", # une correction manuelle gagne toujours
"health_connect", # agrégateur Android : donnée la plus complète/fiable
"fitshow", # spécifique tapis : fiable pour distance/kcal du tapis mais partiel
"csv_import",
"api",
]
```
Algorithme de fusion (exécuté en lecture par le service, pas de table matérialisée en v1) :
```python
def merge_activity_day(rows: list[ActivityDaily]) -> MergedActivity:
"""rows = toutes les lignes (user, date) triées par priorité croissante d'index
dans ACTIVITY_SOURCE_PRIORITY (les sources inconnues vont en dernier)."""
merged = MergedActivity(date=rows[0].date)
for field in ("steps", "active_kcal", "total_kcal", "distance_m",
"active_minutes", "floors"):
for row in rows: # ordre = priorité décroissante
value = getattr(row, field)
if value is not None:
setattr(merged, field, value)
merged.field_sources[field] = row.source # traçabilité UI
break
return merged
```
Points importants :
- La réponse API expose `field_sources` (dict champ → source retenue) pour que l'UI affiche l'origine (« Pas : Health Connect »).
- **On n'additionne jamais deux sources** (risque de double comptage : FitShow est déjà agrégé dans Health Connect si l'app y écrit).
- L'ordre est stocké en dur en v1 ; prévoir une table `user_settings` clé/valeur en v2 si l'utilisateur veut le personnaliser.
### 3.6 Table `workouts`
Séances de sport (tapis FitShow, autres sports, saisie manuelle).
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `started_at` | `DateTime(timezone=True)` | non | Début |
| `ended_at` | `DateTime(timezone=True)` | non | Fin |
| `sport_type` | `Enum(SportType, name="sport_type")` | non | Type de sport |
| `sport_label` | `String(100)` | oui | Précision libre quand `sport_type='other'` |
| `kcal` | `Numeric(7, 1)` | oui | Calories brûlées annoncées |
| `distance_m` | `Integer` | oui | Distance |
| `steps` | `Integer` | oui | Pas de la séance si fournis |
| `avg_hr` | `Integer` | oui | FC moyenne (bpm) |
| `max_hr` | `Integer` | oui | FC max (bpm) |
| `avg_speed_kmh` | `Numeric(4, 1)` | oui | Vitesse moyenne (tapis) |
| `elevation_m` | `Integer` | oui | Dénivelé |
| `is_hidden` | `Boolean` | non (déf. `false`) | Marqué doublon inter-sources (cf. ci-dessous) |
| `source` | `String(32)` | non | |
| `external_id` | `String(128)` | oui | Dédup |
| `note` | `String(255)` | oui | |
| `raw` | `JSONB` | oui | Payload source complet (FitShow : splits, courbe FC…) |
```python
class SportType(str, enum.Enum):
TREADMILL_WALK = "treadmill_walk"
TREADMILL_RUN = "treadmill_run"
WALKING = "walking"
RUNNING = "running"
CYCLING = "cycling"
SWIMMING = "swimming"
STRENGTH = "strength"
HIIT = "hiit"
YOGA = "yoga"
HIKING = "hiking"
OTHER = "other"
```
Contraintes / index :
- `Index("ix_workouts_user_started", "user_id", "started_at")`.
- Index unique partiel `uq_workouts_user_source_extid` (§1.5) — dédup principal.
- `CheckConstraint("ended_at > started_at", name="ck_workouts_duration")`.
**Dédup inter-sources (chevauchement)** : une séance tapis peut arriver via FitShow **et** Health Connect avec des `external_id` différents. Règle appliquée par le service à l'insertion :
```python
def flag_overlapping_duplicates(new_wo):
overlaps = find_workouts(
user_id=new_wo.user_id,
started_at < new_wo.ended_at, ended_at > new_wo.started_at,
source != new_wo.source, is_hidden == False)
for other in overlaps:
inter = overlap_seconds(new_wo, other)
shorter = min(duration(new_wo), duration(other))
if inter / shorter >= 0.8: # 80 % de recouvrement
loser = lower_priority(new_wo, other, ACTIVITY_SOURCE_PRIORITY)
loser.is_hidden = True # conservé mais exclu des stats
```
Les stats et listes excluent `is_hidden = true` par défaut (`?include_hidden=true` pour audit).
### 3.7 Table `goals`
Objectifs de poids. Historisés (un objectif atteint/abandonné reste en base) ; **un seul objectif actif** à la fois.
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `mode` | `Enum(GoalMode, name="goal_mode")` | non | Comment le budget est dérivé (cf. §5.3) |
| `start_date` | `Date` | non | Début de l'objectif (jour local) |
| `start_weight_kg` | `Numeric(5, 2)` | non | Poids (tendance) au démarrage — figé à la création |
| `target_weight_kg` | `Numeric(5, 2)` | non | Poids cible |
| `target_date` | `Date` | oui | Échéance (requis si `mode='target_date'`) |
| `weekly_rate_kg` | `Numeric(4, 2)` | oui | Rythme visé en kg/semaine, **positif = perte** (requis si `mode='weekly_rate'`) |
| `status` | `Enum(GoalStatus, name="goal_status")` | non (déf. `'active'`) | |
| `note` | `String(255)` | oui | |
```python
class GoalMode(str, enum.Enum):
WEEKLY_RATE = "weekly_rate" # l'utilisateur fixe kg/semaine → budget dérivé
TARGET_DATE = "target_date" # l'utilisateur fixe la date → rythme dérivé
MAINTAIN = "maintain" # maintien : budget = TDEE
class GoalStatus(str, enum.Enum):
ACTIVE = "active"
COMPLETED = "completed"
ABANDONED = "abandoned"
```
Contraintes / index :
- Index unique partiel : `Index("uq_goals_user_active", "user_id", unique=True, postgresql_where=text("status = 'active'"))` — un seul objectif actif.
- `CheckConstraint("weekly_rate_kg IS NULL OR (weekly_rate_kg > -1.01 AND weekly_rate_kg <= 1.5)", name="ck_goals_rate_sane")` (négatif = prise de masse autorisée, bornée).
- `CheckConstraint("mode <> 'target_date' OR target_date IS NOT NULL", name="ck_goals_target_date")`.
- `CheckConstraint("mode <> 'weekly_rate' OR weekly_rate_kg IS NOT NULL", name="ck_goals_weekly_rate")`.
---
## 4. Module NUTRITION
### 4.1 Table `food_entries`
Journal alimentaire. Alimenté par l'export Foodvisor (CSV/JSON), la saisie manuelle et les favoris.
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `eaten_at` | `DateTime(timezone=True)` | non | Instant de consommation |
| `meal` | `Enum(MealType, name="meal_type")` | non | Repas |
| `name` | `String(200)` | non | Nom de l'aliment (FR, tel que saisi/importé) |
| `brand` | `String(100)` | oui | Marque |
| `quantity` | `Numeric(8, 2)` | non | Quantité consommée |
| `unit` | `String(20)` | non (déf. `'g'`) | Unité libre normalisée : `g`, `ml`, `portion`, `piece` |
| `kcal` | `Numeric(7, 1)` | non | Calories **de la quantité consommée** (pas pour 100 g) |
| `protein_g` | `Numeric(6, 1)` | oui | Protéines (quantité consommée) |
| `carbs_g` | `Numeric(6, 1)` | oui | Glucides |
| `fat_g` | `Numeric(6, 1)` | oui | Lipides |
| `fiber_g` | `Numeric(6, 1)` | oui | Fibres |
| `sugar_g` | `Numeric(6, 1)` | oui | Sucres (présent dans exports Foodvisor) |
| `sat_fat_g` | `Numeric(6, 1)` | oui | Acides gras saturés |
| `sodium_mg` | `Numeric(8, 1)` | oui | Sodium |
| `source` | `String(32)` | non (déf. `'manual'`) | |
| `external_id` | `String(128)` | oui | Dédup Foodvisor / CSV |
| `raw` | `JSONB` | oui | Ligne d'export brute |
```python
class MealType(str, enum.Enum):
BREAKFAST = "breakfast" # petit-déjeuner
LUNCH = "lunch" # déjeuner
DINNER = "dinner" # dîner
SNACK = "snack" # collation
```
Contraintes / index :
- `Index("ix_food_entries_user_eaten", "user_id", "eaten_at")`.
- Index unique partiel `uq_food_entries_user_source_extid` (§1.5).
- `CheckConstraint("kcal >= 0 AND quantity > 0", name="ck_food_entries_positive")`.
**Toutes les valeurs nutritionnelles sont absolues** (pour la quantité saisie), jamais « pour 100 g » : c'est le format des exports Foodvisor et cela évite toute ambiguïté d'agrégation. La conversion pour-100g → absolu est la responsabilité de l'importeur.
### 4.2 Table `food_favorites`
Aliments favoris pour la saisie rapide. Les **récents** ne sont pas une table : c'est une requête sur `food_entries` (cf. endpoint `/nutrition/recent`). Les macros sont stockées **pour la quantité par défaut** (même convention absolue que `food_entries`).
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `name` | `String(200)` | non | |
| `brand` | `String(100)` | oui | |
| `default_quantity` | `Numeric(8, 2)` | non | Quantité par défaut proposée |
| `unit` | `String(20)` | non (déf. `'g'`) | |
| `kcal` | `Numeric(7, 1)` | non | Pour `default_quantity` |
| `protein_g` | `Numeric(6, 1)` | oui | |
| `carbs_g` | `Numeric(6, 1)` | oui | |
| `fat_g` | `Numeric(6, 1)` | oui | |
| `fiber_g` | `Numeric(6, 1)` | oui | |
| `default_meal` | `Enum(MealType)` | oui | Pré-sélection du repas |
| `use_count` | `Integer` | non (déf. `0`) | Incrémenté à chaque utilisation |
| `last_used_at` | `DateTime(timezone=True)` | oui | Pour trier « favoris récents » |
Contraintes / index :
- `UniqueConstraint("user_id", "name", "brand", name="uq_food_favorites_user_name_brand")` (NB : PostgreSQL considère deux `NULL` comme distincts — utiliser `postgresql_nulls_not_distinct=True` sur cette contrainte, disponible en PG15+, pour que `brand NULL` dédoublonne aussi).
- `Index("ix_food_favorites_user_usage", "user_id", "use_count")`.
Lors de la saisie via un favori : le service copie les valeurs dans `food_entries` en les **proratisant** si l'utilisateur modifie la quantité (`kcal_entry = kcal_fav × quantity_entry / default_quantity`), puis incrémente `use_count` et met à jour `last_used_at`.
### 4.3 Table `water_entries`
Hydratation (optionnelle mais spécifiée pour l'implémentation directe).
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `drunk_at` | `DateTime(timezone=True)` | non | Instant |
| `volume_ml` | `Integer` | non | Volume (ex. 250) |
| `source` | `String(32)` | non (déf. `'manual'`) | |
| `external_id` | `String(128)` | oui | Dédup Health Connect (hydration records) |
Contraintes / index :
- `Index("ix_water_entries_user_drunk", "user_id", "drunk_at")`.
- Index unique partiel `uq_water_entries_user_source_extid`.
- `CheckConstraint("volume_ml > 0 AND volume_ml <= 5000", name="ck_water_entries_volume")`.
---
## 5. Calculs SANTÉ / NUTRITION (formules exactes + pseudocode)
Tous les calculs vivent dans `app/health/calculations.py` (fonctions pures, testables unitairement) et sont orchestrés par les endpoints de stats. Constante centrale :
```python
KCAL_PER_KG_FAT = 7700 # 1 kg de masse corporelle ≈ 7700 kcal
EMA_ALPHA = 0.1 # lissage tendance poids (Hacker's Diet)
MIN_SLOPE_KG_PER_DAY = 0.005 # sous ce seuil, pente considérée nulle
```
### 5.1 BMR — Mifflin-St Jeor
Entrées : `weight_kg` (poids **tendance** courant, cf. §5.5 ; repli : dernière pesée), `height_cm`, `age` (années révolues à la date du calcul), `sex`.
```
homme : BMR = 10·weight_kg + 6.25·height_cm 5·age + 5
femme : BMR = 10·weight_kg + 6.25·height_cm 5·age 161
other : BMR = 10·weight_kg + 6.25·height_cm 5·age 78 # moyenne des deux
```
```python
def bmr_mifflin(weight_kg: float, height_cm: float, age: int, sex: Sex) -> float:
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
offset = {Sex.MALE: 5, Sex.FEMALE: -161, Sex.OTHER: -78}[sex]
return base + offset
```
### 5.2 TDEE (dépense énergétique totale) — estimé vs mesuré
Facteurs d'activité (`ACTIVITY_FACTORS`) :
| `activity_level` | Facteur |
|---|---|
| `sedentary` | 1.2 |
| `light` | 1.375 |
| `moderate` | 1.55 |
| `active` | 1.725 |
| `very_active` | 1.9 |
**TDEE effectif d'un jour donné** — ordre de préférence :
1. `total_kcal` de l'activité fusionnée du jour (mesure directe de l'appareil/Health Connect) — si présent et plausible (`> 0.8 × BMR`, garde-fou contre les jours partiels) ;
2. sinon, si seulement `active_kcal` est présent : `TDEE = BMR + active_kcal` (léger sous-comptage assumé : la thermogenèse alimentaire n'est pas incluse — documenté dans l'UI) ;
3. sinon : `TDEE = BMR × activity_factor`.
```python
def tdee_effective(day: date, profile, merged_activity) -> TdeeResult:
bmr = bmr_mifflin(trend_weight(day), profile.height_cm,
age_on(day, profile.birthdate), profile.sex)
a = merged_activity # peut être None
if a and a.total_kcal and a.total_kcal > 0.8 * bmr:
return TdeeResult(kcal=float(a.total_kcal), method="measured_total")
if a and a.active_kcal is not None:
return TdeeResult(kcal=bmr + float(a.active_kcal), method="bmr_plus_active")
return TdeeResult(kcal=bmr * ACTIVITY_FACTORS[profile.activity_level],
method="estimated")
```
La réponse API renvoie toujours `method` pour que l'UI signale « mesuré » vs « estimé ». Pour les affichages agrégés (budget), on utilise aussi `tdee_smoothed = moyenne mobile 7 jours` de `tdee_effective` afin d'éviter qu'un jour très actif gonfle le budget du lendemain.
### 5.3 Budget calorique quotidien
Déficit quotidien visé à partir de l'objectif actif :
```
deficit_kcal_day = weekly_rate_kg × 7700 / 7 = weekly_rate_kg × 1100
budget_kcal_day = TDEE_effectif(jour) deficit_kcal_day
```
Selon `goals.mode` :
- **`weekly_rate`** : `weekly_rate_kg` vient de l'objectif (positif = perte ⇒ déficit ; négatif = prise ⇒ surplus).
- **`target_date`** : le rythme est **recalculé chaque jour** à partir de la tendance courante :
`weekly_rate_kg = (trend_weight_now target_weight_kg) / max(weeks_remaining, 1)`
avec `weeks_remaining = (target_date today).days / 7`. Rythme borné à `[0.5, +1.0]` kg/semaine ; si le rythme requis dépasse 1.0 kg/sem, l'API renvoie un avertissement `rate_clamped=true` (l'échéance n'est plus tenable).
- **`maintain`** : `deficit = 0`, `budget = TDEE`.
**Plancher de sécurité** : `budget = max(budget, floor)` avec `floor = profile.calorie_floor_kcal` si défini, sinon `1500` (homme) / `1200` (femme, `other`). Si le plancher est appliqué, l'API renvoie `floor_applied=true`.
```python
def daily_budget(day, profile, goal, tdee_smoothed_kcal) -> BudgetResult:
if goal is None or goal.mode == GoalMode.MAINTAIN:
rate = 0.0
elif goal.mode == GoalMode.WEEKLY_RATE:
rate = float(goal.weekly_rate_kg)
else: # TARGET_DATE
weeks_left = max(((goal.target_date - day).days) / 7, 1.0)
rate = (trend_weight(day) - float(goal.target_weight_kg)) / weeks_left
rate = clamp(rate, -0.5, 1.0)
deficit = rate * KCAL_PER_KG_FAT / 7 # = rate × 1100
budget = tdee_smoothed_kcal - deficit
floor = profile.calorie_floor_kcal or (1500 if profile.sex == Sex.MALE else 1200)
return BudgetResult(kcal=max(budget, floor), deficit_target=deficit,
floor_applied=budget < floor)
```
### 5.4 Bilan énergétique quotidien
Pour chaque jour local `d` :
```
intake_kcal(d) = Σ food_entries.kcal où jour_local(eaten_at) = d
balance(d) = intake_kcal(d) TDEE_effectif(d) # négatif = déficit
vs_budget(d) = intake_kcal(d) budget_kcal(d) # négatif = sous le budget
```
Les jours **sans aucune** `food_entry` sont renvoyés avec `intake=null, balance=null` (jour non tracké ≠ jeûne) et **exclus** des cumuls du §5.7.
### 5.5 Tendance de poids — EMA (Hacker's Diet) + pente par régression
**Série d'entrée** : une valeur par jour local = **première pesée du jour** (min `measured_at` du jour). Jours sans pesée : pas de point (la formule gère les trous).
**EMA avec correction des trous** (équivalent à appliquer l'EMA quotidienne en maintenant la tendance les jours sans mesure) :
```
alpha = 0.1
alpha_eff = 1 (1 alpha)^gap_days # gap_days = jours écoulés depuis la mesure précédente
trend_i = trend_{i1} + alpha_eff × (weight_i trend_{i1})
trend_0 = weight_0 # initialisation sur la première pesée
```
```python
def weight_trend(entries: list[tuple[date, float]], alpha: float = 0.1):
"""entries triées par date croissante, une par jour. Renvoie [(date, trend)]."""
out = []
prev_date, trend = None, None
for d, w in entries:
if trend is None:
trend = w
else:
gap = (d - prev_date).days
alpha_eff = 1 - (1 - alpha) ** gap
trend = trend + alpha_eff * (w - trend)
out.append((d, round(trend, 2)))
prev_date = d
return out
```
**Pente par régression linéaire** (moindres carrés ordinaires) sur la **série de tendance** (moins bruitée que le poids brut), fenêtres glissantes de 14 et 30 jours. `t_i` = numéro de jour (float), `w_i` = tendance :
```
slope_kg_day = Σ (t_i t̄)(w_i w̄) / Σ (t_i t̄)²
slope_kg_week = slope_kg_day × 7
```
```python
def regression_slope(points: list[tuple[date, float]]) -> float | None:
if len(points) < 3:
return None # pas assez de données
t0 = points[0][0]
ts = [(d - t0).days for d, _ in points]
ws = [w for _, w in points]
t_mean, w_mean = mean(ts), mean(ws)
denom = sum((t - t_mean) ** 2 for t in ts)
if denom == 0:
return None
return sum((t - t_mean) * (w - w_mean) for t, w in zip(ts, ws)) / denom
```
L'API expose `slope_14d` et `slope_30d` (kg/jour et kg/semaine). Règle d'usage : **14 j** pour la réactivité (affichage « rythme actuel »), **30 j** pour la projection (plus stable).
### 5.6 Projection de la date d'atteinte de l'objectif
Deux projections, toutes deux renvoyées :
**a) Projection « tendance »** (au rythme constaté) — utilise `slope_30d` (repli `slope_14d` si < 30 j de données) :
```python
def project_target_date(trend_now, target_weight, slope_kg_day, today):
delta = trend_now - target_weight # >0 s'il reste du poids à perdre
if abs(delta) < 0.1:
return Projection(status="reached")
losing_needed = delta > 0
if slope_kg_day is None or abs(slope_kg_day) < MIN_SLOPE_KG_PER_DAY \
or (losing_needed and slope_kg_day >= 0) \
or (not losing_needed and slope_kg_day <= 0):
return Projection(status="not_converging") # UI : « non atteignable au rythme actuel »
days = delta / (-slope_kg_day) if losing_needed else delta / (-slope_kg_day)
# delta et slope sont de signes opposés quand ça converge ⇒ days > 0
days = abs(delta / slope_kg_day)
if days > 3650:
return Projection(status="not_converging")
return Projection(status="ok", date=today + timedelta(days=round(days)))
```
**b) Projection « plan »** (au rythme théorique de l'objectif) : `days = delta / (weekly_rate_kg / 7)` — sert de ligne de référence sur le graphique poids (droite `start_weight_kg → target_weight_kg`).
Le graphique « poids » du dashboard superpose : pesées brutes (points), tendance EMA (courbe), droite du plan, et bande de projection tendance.
### 5.7 Comparaison déficit cumulé vs perte réelle (calibration du TDEE)
Sur une période `[d1, d2]` (défaut : depuis `goal.start_date`), en n'utilisant que les jours **trackés** (intake non nul) :
```
expected_change_kg = Σ balance(d) / 7700 # somme des bilans quotidiens
actual_change_kg = trend(d2) trend(d1) # variation de la TENDANCE (pas du brut)
gap_kg = actual_change_kg expected_change_kg
```
Interprétation renvoyée par l'API :
- `gap ≈ 0` (±10 % du changement attendu) : le modèle TDEE est bien calibré ;
- `gap < 0` (perte plus rapide que prévu) : TDEE réel > TDEE modélisé, ou intake sous-estimé n'est pas le cas ici — proposer `tdee_correction_kcal` ;
- `gap > 0` : TDEE surestimé et/ou intake sous-déclaré.
**Correction adaptative proposée** (affichée, jamais appliquée automatiquement) :
```
tracked_days = nombre de jours avec intake sur [d1, d2]
tdee_adaptive_kcal_day = mean(intake) actual_change_kg × 7700 / tracked_days
tdee_correction_kcal = tdee_adaptive mean(tdee_effective)
```
Fenêtre minimale : 21 jours trackés, sinon `status="insufficient_data"`.
---
## 6. Module VAPE / SEVRAGE TABAC
### 6.1 Table `vape_settings`
Une ligne par utilisateur. Référentiel du sevrage et valeurs par défaut.
| Colonne | Type SQLAlchemy | Null | Défaut | Description |
|---|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | — | |
| `quit_date` | `Date` | non | — | Date d'arrêt de la cigarette (jour local) |
| `cigs_per_day_before` | `Numeric(4, 1)` | non | — | Cigarettes/jour avant arrêt (ex. 15.0) |
| `cig_pack_price` | `Numeric(6, 2)` | non | — | Prix du paquet en € (valeur actuelle de référence) |
| `cigs_per_pack` | `Integer` | non | `20` | Cigarettes par paquet |
| `default_nicotine_mg_ml` | `Numeric(4, 1)` | non | — | Taux de nicotine par défaut des liquides (mg/ml) |
| `currency` | `String(3)` | non | `'EUR'` | Code ISO 4217 (prêt pour multi-devise, UI fige €) |
Contraintes :
- `UniqueConstraint("user_id", name="uq_vape_settings_user")`.
- `CheckConstraint("cigs_per_pack > 0 AND cig_pack_price >= 0", name="ck_vape_settings_pack")`.
*Note d'évolution* : le prix du paquet évolue (France : hausses régulières). En v1 on stocke la valeur courante — les économies « théoriques » utilisent donc le prix actuel (léger avantage à l'utilisateur, assumé et documenté dans l'UI). Une table d'historique `cig_price_history(user_id, effective_date, pack_price)` est prévue en v2 ; ne pas l'implémenter maintenant.
### 6.2 Table `products` (catalogue vape)
Catalogue des produits achetés : résistances, bases, boosters, arômes, matériel, pods. Sert aux recettes DIY, au suivi des résistances et aux achats.
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `kind` | `Enum(ProductKind, name="product_kind")` | non | Type de produit |
| `name` | `String(150)` | non | Ex. « Base 50/50 1L », « GT Cores Mesh 0.6Ω » |
| `brand` | `String(100)` | oui | |
| `price` | `Numeric(8, 2)` | non | Prix catalogue du conditionnement (€) |
| `size_value` | `Numeric(8, 2)` | non | Taille du conditionnement (ex. 1000, 10, 5) |
| `size_unit` | `Enum(SizeUnit, name="size_unit")` | non | `ml` / `unit` / `g` |
| `nicotine_mg_ml` | `Numeric(4, 1)` | oui | Boosters uniquement (ex. 20.0) |
| `vg_pct` | `Numeric(4, 1)` | oui | % VG (bases/boosters/arômes, ex. 50.0) |
| `ohm` | `Numeric(4, 2)` | oui | Résistance en ohms (kind=`coil`) |
| `is_archived` | `Boolean` | non (déf. `false`) | Produit plus utilisé (masqué des listes) |
| `note` | `String(255)` | oui | |
```python
class ProductKind(str, enum.Enum):
COIL = "coil" # résistance
BASE = "base" # base PG/VG
BOOSTER = "booster" # booster de nicotine
AROMA = "aroma" # arôme concentré
HARDWARE = "hardware" # matériel (box, clearomiseur…) — hors coût/ml
POD = "pod" # cartouche pod
class SizeUnit(str, enum.Enum):
ML = "ml"
UNIT = "unit" # à l'unité (résistances : boîte de 5 ⇒ size_value=5, size_unit=unit)
G = "g"
```
Contraintes / index :
- `UniqueConstraint("user_id", "kind", "name", "brand", name="uq_products_user_kind_name_brand", postgresql_nulls_not_distinct=True)`.
- `Index("ix_products_user_kind", "user_id", "kind")`.
- `CheckConstraint("price >= 0 AND size_value > 0", name="ck_products_price_size")`.
- `CheckConstraint("kind <> 'booster' OR nicotine_mg_ml IS NOT NULL", name="ck_products_booster_nic")`.
**Propriété dérivée (hybrid property, pas de colonne)** : `unit_price = price / size_value` → €/ml pour les liquides, €/unité pour les résistances.
### 6.3 Tables `mixes` + `mix_components` (recettes DIY)
Une recette = un volume total, un taux de nicotine cible et des composants. Le coût est **calculé, jamais stocké** (il suit les prix catalogue). Un seul mix « actif » (celui actuellement vapoté) — il fournit le `cost_per_ml` et le taux de nicotine par défaut des recharges.
`mixes` :
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `name` | `String(150)` | non | Ex. « Fraise 6 mg 50/50 » |
| `total_ml` | `Numeric(7, 1)` | non | Volume total préparé (ex. 260.0) |
| `target_nicotine_mg_ml` | `Numeric(4, 1)` | non | Taux visé (ex. 6.0) |
| `is_active` | `Boolean` | non (déf. `false`) | Mix en cours d'utilisation |
| `is_archived` | `Boolean` | non (déf. `false`) | |
| `note` | `String(255)` | oui | |
`mix_components` :
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `mix_id` | `BigInteger, FK mixes.id, ondelete=CASCADE` | non | |
| `product_id` | `BigInteger, FK products.id, ondelete=RESTRICT` | non | Base, booster ou arôme |
| `quantity` | `Numeric(7, 2)` | non | Quantité utilisée, dans `product.size_unit` (ml en pratique) |
Contraintes / index :
- `Index("uq_mixes_user_active", "user_id", unique=True, postgresql_where=text("is_active = true"))` — un seul mix actif.
- `UniqueConstraint("mix_id", "product_id", name="uq_mix_components_mix_product")`.
- `CheckConstraint("quantity > 0", name="ck_mix_components_qty")`.
- (`mix_components` n'a pas de `user_id` : l'appartenance passe par `mixes` ; les jointures de contrôle d'accès vérifient `mixes.user_id`.)
**Calculs de recette** (service `app/vape/mixing.py`) :
```
cost_total = Σ composant.quantity × (product.price / product.size_value)
cost_per_ml = cost_total / mix.total_ml
nicotine_check_mg_ml = Σ (qty_booster × booster.nicotine_mg_ml) / total_ml
vg_pct_mix = Σ (qty_i × vg_pct_i) / total_ml # si tous les vg_pct connus
```
L'API renvoie `nicotine_check_mg_ml` à côté de `target_nicotine_mg_ml` ; si l'écart dépasse 10 %, réponse avec `warning="nicotine_mismatch"`.
**Assistant de recette** (endpoint calculateur, ne persiste rien) — entrées : `total_ml`, `target_nic`, `booster_product_id` (taux `n_b`), `aroma_pct`, `base_product_id` :
```
booster_ml = total_ml × target_nic / n_b
aroma_ml = total_ml × aroma_pct / 100
base_ml = total_ml booster_ml aroma_ml # erreur si ≤ 0
```
### 6.4 Table `liquid_entries` (consommation de liquide)
Journal de consommation. Deux modes de saisie coexistent, distingués par `kind` :
- **`refill`** : évènement de recharge du réservoir/pod (« j'ai remis 4 ml »). La consommation du jour = somme des recharges du jour (approximation assumée : le liquide rechargé est considéré consommé le jour même).
- **`daily_total`** : total quotidien saisi ou importé directement. **S'il existe un `daily_total` pour un jour, il remplace la somme des `refill` de ce jour** (règle appliquée en lecture).
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `entry_date` | `Date` | non | Jour local de consommation |
| `kind` | `Enum(LiquidEntryKind, name="liquid_entry_kind")` | non (déf. `'refill'`) | `refill` / `daily_total` |
| `ml` | `Numeric(6, 2)` | non | Volume (ex. 4.00) |
| `nicotine_mg_ml` | `Numeric(4, 1)` | oui | Override ; sinon mix actif, sinon `vape_settings.default_nicotine_mg_ml` |
| `mix_id` | `BigInteger, FK mixes.id, ondelete=SET NULL` | oui | Mix concerné (rempli automatiquement = mix actif à la saisie) |
| `source` | `String(32)` | non (déf. `'manual'`) | |
| `external_id` | `String(128)` | oui | Dédup imports |
| `note` | `String(255)` | oui | |
```python
class LiquidEntryKind(str, enum.Enum):
REFILL = "refill"
DAILY_TOTAL = "daily_total"
```
Contraintes / index :
- `Index("ix_liquid_entries_user_date", "user_id", "entry_date")`.
- Index unique partiel : `Index("uq_liquid_entries_user_date_dailytotal", "user_id", "entry_date", unique=True, postgresql_where=text("kind = 'daily_total'"))` — au plus un total quotidien par jour.
- Index unique partiel `uq_liquid_entries_user_source_extid` (§1.5).
- `CheckConstraint("ml > 0 AND ml <= 100", name="ck_liquid_entries_ml")`.
**Consommation quotidienne effective** (fonction de lecture centrale) :
```python
def daily_ml(user_id, d: date) -> Decimal | None:
rows = liquid_entries_for(user_id, d)
totals = [r for r in rows if r.kind == "daily_total"]
if totals:
return totals[0].ml
refills = [r for r in rows if r.kind == "refill"]
return sum(r.ml for r in refills) if refills else None # None = jour non tracké
```
**Nicotine effective d'une entrée** : `entry.nicotine_mg_ml or entry.mix.target_nicotine_mg_ml or settings.default_nicotine_mg_ml`.
### 6.5 Table `coil_changes`
Changements de résistance. La durée de vie est dérivée de l'écart entre deux changements consécutifs.
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `changed_at` | `DateTime(timezone=True)` | non | Instant de pose de la **nouvelle** résistance |
| `product_id` | `BigInteger, FK products.id, ondelete=RESTRICT` | oui | Modèle de résistance posé (kind=`coil`/`pod`) |
| `reason` | `String(50)` | oui | Libre : « goût de brûlé », « préventif »… |
| `note` | `String(255)` | oui | |
Contraintes / index :
- `Index("ix_coil_changes_user_changed", "user_id", "changed_at")`.
**Dérivés** (service, cf. §7.3) : durée de vie de la résistance *i* = `changed_at_{i+1} changed_at_i` (la dernière posée est « en cours », durée de vie provisoire = `now changed_at_last`). Volume traversé = Σ `daily_ml` sur l'intervalle.
### 6.6 Table `purchases` (achats réels)
Dépenses réelles vape (pour les économies « réelles » et le coût réel/ml).
| Colonne | Type SQLAlchemy | Null | Description |
|---|---|---|---|
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
| `purchased_on` | `Date` | non | Date d'achat (jour local) |
| `product_id` | `BigInteger, FK products.id, ondelete=RESTRICT` | non | Produit acheté |
| `qty` | `Numeric(6, 2)` | non (déf. `1`) | Nombre de conditionnements (ex. 2 boîtes) |
| `unit_price` | `Numeric(8, 2)` | oui | Prix payé par conditionnement ; défaut = `product.price` |
| `note` | `String(255)` | oui | |
Contraintes / index :
- `Index("ix_purchases_user_date", "user_id", "purchased_on")`.
- `CheckConstraint("qty > 0", name="ck_purchases_qty")`.
Dérivé : `total = qty × coalesce(unit_price, product.price)`.
---
## 7. Calculs VAPE (formules exactes + pseudocode)
Module `app/vape/calculations.py`. Convention : les moyennes « /jour » se calculent sur une fenêtre glissante paramétrable `window` ∈ {7, 30, all} (défaut 30 j), en **ignorant les jours non trackés** (`daily_ml is None`) — sauf mention contraire.
### 7.1 Coût par ml du mix actif
```
cost_per_ml = Σ (component.quantity × product.price / product.size_value) / mix.total_ml
```
Repli si aucun mix actif : coût moyen pondéré des achats de liquides (`kind ∈ {base, booster, aroma}`) sur 90 j : `Σ totaux_achats / Σ ml_achetés` ; si aucune donnée, `cost_per_ml = null` et les métriques de coût sont renvoyées `null` (l'UI invite à créer un mix).
### 7.2 Consommation moyenne
```
ml_per_day(window) = mean(daily_ml(d) pour d ∈ window, daily_ml non null)
tracked_days_ratio = jours trackés / jours de la fenêtre # indicateur de fiabilité
```
### 7.3 Durée de vie moyenne des résistances et amortissement
```python
def coil_stats(user_id, last_n: int = 5) -> CoilStats:
changes = coil_changes_sorted(user_id) # par changed_at croissant
if len(changes) < 2:
return CoilStats(status="insufficient_data")
intervals = []
for prev, nxt in pairwise(changes):
days = (nxt.changed_at - prev.changed_at).total_seconds() / 86400
ml = sum_daily_ml(user_id, prev.changed_at.date(), nxt.changed_at.date())
intervals.append((days, ml))
recent = intervals[-last_n:] # moyenne sur les 5 derniers cycles
avg_days = mean(d for d, _ in recent)
avg_ml = mean(m for _, m in recent if m is not None)
current_age_days = (now() - changes[-1].changed_at).total_seconds() / 86400
return CoilStats(avg_lifespan_days=avg_days, avg_ml_through_coil=avg_ml,
current_coil_age_days=current_age_days,
current_coil_product_id=changes[-1].product_id)
```
**Coût d'amortissement résistance** :
```
coil_unit_price = price / size_value (du produit coil le plus récemment posé)
coil_cost_per_day = coil_unit_price / avg_lifespan_days
```
Repli si < 2 changements : `avg_lifespan_days = 14` (constante `DEFAULT_COIL_LIFESPAN_DAYS`, affichée comme « estimation »).
### 7.4 Coût vape par jour
```
vape_cost_per_day = ml_per_day × cost_per_ml + coil_cost_per_day
```
(Le matériel `hardware` n'est **pas** amorti dans le coût/jour v1 — il apparaît uniquement dans les dépenses réelles via `purchases`.)
**Coût réel par jour** (basé sur les achats) : `real_cost_per_day(window) = Σ purchases.total sur window / jours de la fenêtre`.
### 7.5 Coût de référence cigarette et économies
```
cig_cost_per_day = cigs_per_day_before / cigs_per_pack × cig_pack_price
days_since_quit = (today_local quit_date).days # ≥ 0
savings_per_day = cig_cost_per_day vape_cost_per_day
cumulative_savings_theoretical = cig_cost_per_day × days_since_quit
Σ_{d=quit_date}^{today} vape_cost(d)
```
Pour le cumul théorique, `vape_cost(d)` = `daily_ml(d) × cost_per_ml + coil_cost_per_day` si le jour est tracké, sinon `ml_per_day(30) × cost_per_ml + coil_cost_per_day` (imputation par la moyenne — les trous de saisie ne gonflent pas artificiellement les économies).
```
cumulative_savings_real = cig_cost_per_day × days_since_quit Σ purchases.total depuis quit_date
```
L'API renvoie les **deux** cumuls (`theoretical` et `real`) ; le dashboard met en avant `real` si `purchases` contient au moins un achat, sinon `theoretical`.
### 7.6 Nicotine par jour et tendance
```
nicotine_mg(d) = Σ sur les entrées du jour : entry.ml × nicotine_effective(entry)
```
(Si le jour est représenté par un `daily_total`, une seule entrée porte tout le volume.) Série renvoyée avec moyenne mobile 7 j. Tendance = pente de régression (même fonction `regression_slope` qu'au §5.5) sur 30 j, en mg/jour², affichée comme « en baisse / stable / en hausse » (seuils : ±0.05 mg/j par jour).
**Équivalence informative** (affichée avec un disclaimer, absorption réelle variable) : `cig_equivalent_nicotine = nicotine_mg(d) / 12` (~12 mg de nicotine contenue par cigarette ; constante `NICOTINE_MG_PER_CIG = 12`, configurable).
### 7.7 Compteur de cigarettes évitées
```
cigarettes_avoided = days_since_quit × cigs_per_day_before
```
Affiché en entier (`floor`). Dérivés d'affichage : `packs_avoided = cigarettes_avoided / cigs_per_pack`, et le temps de vie « récupéré » informatif : `time_regained_minutes = cigarettes_avoided × 11` (constante `MINUTES_PER_CIG = 11`, estimation classique de 11 min de vie par cigarette — libellé UI : « estimation indicative »).
### 7.8 Jalons santé après arrêt (timeline OMS)
Table **statique en code** (`app/vape/milestones.py`), pas en base. Offsets depuis `quit_date` (datetime local à minuit). Libellés = clés i18n, textes FR par défaut :
| `code` | Offset | Libellé FR |
|---|---|---|
| `hr_bp_normal` | 20 min | Fréquence cardiaque et tension redescendent |
| `co_halved` | 8 h | Le monoxyde de carbone sanguin diminue de moitié |
| `co_normal` | 24 h | Monoxyde de carbone éliminé ; les poumons commencent à évacuer les résidus |
| `nicotine_out` | 48 h | Plus de nicotine dans le corps ; goût et odorat s'améliorent |
| `breathing_easier` | 72 h | Respiration plus facile, énergie en hausse (bronches détendues) |
| `circulation` | 14 j | Circulation sanguine améliorée |
| `lung_function` | 90 j | Fonction pulmonaire améliorée jusqu'à +30 % |
| `cilia_recovery` | 270 j | Cils bronchiques régénérés ; toux et essoufflement diminuent |
| `chd_risk_half` | 365 j | Risque de maladie coronarienne réduit de moitié |
| `stroke_risk_normal` | 5 ans | Risque d'AVC ramené à celui d'un non-fumeur |
| `lung_cancer_half` | 10 ans | Risque de cancer du poumon réduit de moitié |
| `chd_risk_normal` | 15 ans | Risque coronarien équivalent à celui d'un non-fumeur |
```python
def milestones(quit_date: date, tz) -> list[MilestoneStatus]:
t0 = midnight_local(quit_date, tz)
now_ = now(tz)
out = []
for m in MILESTONES: # (code, offset_timedelta, label_key)
reached_at = t0 + m.offset
out.append(MilestoneStatus(
code=m.code, reached_at=reached_at, achieved=now_ >= reached_at,
progress_pct=min(100, 100 * (now_ - t0) / m.offset)))
return out
```
---
## 8. Endpoints API
Préfixe global : `/api/v1`. Auth JWT obligatoire partout (`Authorization: Bearer`). Pagination des listes : `?limit=` (déf. 50, max 500) `&offset=`, tri décroissant sur la date métier. Filtres de période : `?from=YYYY-MM-DD&to=YYYY-MM-DD` (jours locaux, bornes incluses).
### 8.1 Format standard des séries (chart-ready pour ECharts)
Toutes les réponses `stats/*` suivent ce contrat :
```json
{
"from": "2026-07-01",
"to": "2026-08-13",
"unit": "kg",
"series": [
{"name": "weight_raw", "type": "scatter", "points": [["2026-07-01", 92.4], ["2026-07-03", 92.1]]},
{"name": "weight_trend", "type": "line", "points": [["2026-07-01", 92.4], ["2026-07-03", 92.25]]}
],
"meta": { "...": "valeurs scalaires spécifiques à l'endpoint" }
}
```
`points` = tableaux `[date_ISO, valeur|null]` directement injectables dans `dataset.source` d'ECharts. Les noms de séries sont des identifiants stables (l'UI mappe vers les libellés FR).
### 8.2 SANTÉ
| Méthode | Chemin | Description |
|---|---|---|
| GET | `/health/profile` | Profil (avec `age`, `bmr`, `tdee_estimated` calculés) |
| PUT | `/health/profile` | Mise à jour du profil |
| GET | `/health/weights` | Liste des pesées (filtres période) |
| POST | `/health/weights` | Créer une pesée |
| PUT | `/health/weights/{id}` | Modifier |
| DELETE | `/health/weights/{id}` | Supprimer |
| GET | `/health/weights/stats` | Séries : `weight_raw`, `weight_trend` (EMA), `plan_line` ; `meta`: `trend_now`, `slope_14d_kg_week`, `slope_30d_kg_week`, `total_change_kg`, `projection` (§5.6) |
| GET | `/health/measurements` | Liste mensurations |
| POST/PUT/DELETE | `/health/measurements[/{id}]` | CRUD |
| GET | `/health/measurements/stats` | Une série par mesure renseignée + `body_fat_navy_pct` si calculable |
| GET | `/health/activity` | Jours **fusionnés** (§3.5) avec `field_sources` ; `?raw=true` renvoie les lignes par source |
| POST | `/health/activity` | Upsert manuel d'un jour (source=`manual`, conflit sur (user,date,source)) |
| DELETE | `/health/activity/{id}` | Supprimer une ligne source |
| GET | `/health/activity/stats` | Séries : `steps`, `active_kcal`, `total_kcal`, `distance_m` + moyennes mobiles 7 j ; `meta`: moyennes de la période |
| GET | `/health/workouts` | Liste (filtres : période, `sport_type`, `include_hidden`) |
| POST/PUT/DELETE | `/health/workouts[/{id}]` | CRUD |
| GET | `/health/workouts/stats` | Séries hebdo : `sessions_count`, `total_kcal`, `total_distance_m`, `total_duration_min` ; répartition par `sport_type` |
| GET | `/health/goals` | Historique des objectifs |
| POST | `/health/goals` | Créer (bascule l'éventuel objectif actif en `abandoned` si `?replace_active=true`, sinon 409) |
| PUT | `/health/goals/{id}` | Modifier / changer `status` |
| GET | `/health/goals/active` | Objectif actif + `daily_budget` (§5.3) + `projection` (§5.6) + avancement (`done_kg`, `remaining_kg`, `pct`) |
| GET | `/health/energy-balance` | Séries par jour : `intake_kcal`, `tdee_kcal` (+`method` par point dans `meta.tdee_methods`), `balance_kcal`, `budget_kcal` ; `meta`: cumuls + comparaison §5.7 (`expected_change_kg`, `actual_change_kg`, `gap_kg`, `tdee_correction_kcal`) |
| GET | `/health/dashboard` | Agrégat du jour : dernier poids/tendance, budget restant du jour (`budget intake`), pas du jour, série poids 30 j — un seul appel pour l'écran d'accueil |
### 8.3 NUTRITION
| Méthode | Chemin | Description |
|---|---|---|
| GET | `/nutrition/entries` | Liste (filtres : période, `meal`, `q` recherche nom) |
| POST | `/nutrition/entries` | Créer (option `favorite_id` pour créer depuis un favori, avec proratisation §4.2) |
| PUT/DELETE | `/nutrition/entries/{id}` | CRUD |
| GET | `/nutrition/days` | Agrégats par jour local : `kcal`, `protein_g`, `carbs_g`, `fat_g`, `fiber_g`, `vs_budget` ; `meta`: moyennes, répartition macros % |
| GET | `/nutrition/days/{date}` | Détail d'un jour groupé par repas (pour l'écran journal) |
| GET | `/nutrition/favorites` | Liste triée par `use_count` desc |
| POST/PUT/DELETE | `/nutrition/favorites[/{id}]` | CRUD |
| GET | `/nutrition/recent` | 20 derniers aliments distincts saisis (dédupliqués sur `(name, brand)`, ordre `eaten_at` desc) — requête sur `food_entries` |
| GET | `/nutrition/water` | Liste entrées eau |
| POST/DELETE | `/nutrition/water[/{id}]` | CRUD |
| GET | `/nutrition/water/stats` | Série `volume_ml` par jour + ligne objectif `water_goal_ml` |
### 8.4 VAPE
| Méthode | Chemin | Description |
|---|---|---|
| GET | `/vape/settings` | Paramètres (404 → l'UI lance l'assistant de configuration) |
| PUT | `/vape/settings` | Upsert des paramètres |
| GET | `/vape/liquids` | Liste consommations (filtres période, `kind`) |
| POST/PUT/DELETE | `/vape/liquids[/{id}]` | CRUD (POST refuse un 2ᵉ `daily_total` le même jour → 409) |
| GET | `/vape/products` | Liste (filtres `kind`, `include_archived`) |
| POST/PUT/DELETE | `/vape/products[/{id}]` | CRUD (DELETE → 409 si référencé ; proposer archivage) |
| GET | `/vape/mixes` | Liste avec `cost_per_ml`, `cost_total`, `nicotine_check_mg_ml` calculés |
| POST/PUT/DELETE | `/vape/mixes[/{id}]` | CRUD (composants imbriqués dans le payload, remplacement complet à l'update) |
| POST | `/vape/mixes/{id}/activate` | Active ce mix (désactive l'ancien) |
| POST | `/vape/mixes/calculator` | Assistant recette §6.3 (stateless : entrées → quantités et coût, ne persiste rien) |
| GET | `/vape/coils` | Liste des changements + durée de vie de chaque cycle |
| POST/PUT/DELETE | `/vape/coils[/{id}]` | CRUD |
| GET | `/vape/coils/stats` | `avg_lifespan_days`, `avg_ml_through_coil`, `current_coil_age_days`, série `lifespan_days` par changement (§7.3) |
| GET | `/vape/purchases` | Liste achats (filtre période, `product_id`) |
| POST/PUT/DELETE | `/vape/purchases[/{id}]` | CRUD |
| GET | `/vape/stats/consumption` | Séries par jour : `ml`, `ml_ma7` (moyenne mobile 7 j) ; `meta`: `ml_per_day_7`, `ml_per_day_30`, `tracked_days_ratio` |
| GET | `/vape/stats/nicotine` | Séries : `nicotine_mg`, `nicotine_mg_ma7` ; `meta`: pente 30 j, statut baisse/stable/hausse, `cig_equivalent` (§7.6) |
| GET | `/vape/stats/costs` | Séries mensuelles : `theoretical_cost`, `real_spend` ; `meta`: `cost_per_ml`, `vape_cost_per_day`, `coil_cost_per_day`, `real_cost_per_day` |
| GET | `/vape/stats/savings` | Série cumulative `savings_theoretical`, `savings_real` depuis `quit_date` ; `meta`: `cig_cost_per_day`, `savings_per_day`, `days_since_quit`, `cigarettes_avoided`, `packs_avoided`, `time_regained_minutes` |
| GET | `/vape/milestones` | Timeline §7.8 : liste `{code, label_fr, reached_at, achieved, progress_pct}` |
| GET | `/vape/dashboard` | Agrégat : ml aujourd'hui, nicotine aujourd'hui, économies cumulées, âge résistance courante, prochain jalon santé |
### 8.5 Points de contact avec le framework d'import (référence)
Définis en détail dans le document connecteurs ; rappelés ici car ils écrivent dans les tables de ce document :
- `POST /imports/upload` (multipart : fichier + `connector` ∈ {`foodvisor`, `fitshow`, `generic_weight_csv`, …}) → job d'import qui upserte via les clés de dédup §1.5.
- `POST /ingest/health-connect` (token d'appareil dédié) : lots JSON de l'app compagnon Android → upsert dans `weight_entries`, `activity_daily` (source=`health_connect`, conflit sur `(user_id, date, source)`), `workouts`, `water_entries` avec `external_id` = UUID du record Health Connect.
- Tous les imports sont **idempotents** : rejouer un fichier ou un lot ne modifie le résultat que si les valeurs source ont changé.
---
## 9. Récapitulatif des contraintes de dédup (aide-mémoire implémentation)
| Table | Clé de dédup connecteurs | Autre unicité |
|---|---|---|
| `weight_entries` | `(user_id, source, external_id)` partiel | `(user_id, measured_at, source)` |
| `body_measurements` | `(user_id, source, external_id)` partiel | — |
| `activity_daily` | — (clé naturelle suffit) | `(user_id, date, source)` **UNIQUE**, cible d'upsert |
| `workouts` | `(user_id, source, external_id)` partiel | + dédup chevauchement 80 % → `is_hidden` |
| `food_entries` | `(user_id, source, external_id)` partiel | — |
| `food_favorites` | — | `(user_id, name, brand)` nulls-not-distinct |
| `water_entries` | `(user_id, source, external_id)` partiel | — |
| `user_profile`, `vape_settings` | — | `(user_id)` |
| `goals` | — | un seul `status='active'` par user (partiel) |
| `liquid_entries` | `(user_id, source, external_id)` partiel | un seul `daily_total` par `(user_id, entry_date)` (partiel) |
| `products` | — | `(user_id, kind, name, brand)` nulls-not-distinct |
| `mixes` | — | un seul `is_active=true` par user (partiel) |
| `mix_components` | — | `(mix_id, product_id)` |
| `coil_changes`, `purchases` | — | — |
## 10. Constantes de calcul (fichier `app/core/constants.py`)
| Constante | Valeur | Usage |
|---|---|---|
| `KCAL_PER_KG_FAT` | 7700 | Conversions déficit ↔ poids |
| `EMA_ALPHA` | 0.1 | Tendance poids |
| `MIN_SLOPE_KG_PER_DAY` | 0.005 | Seuil de pente « nulle » |
| `ACTIVITY_FACTORS` | 1.2 / 1.375 / 1.55 / 1.725 / 1.9 | TDEE estimé |
| `CALORIE_FLOOR_MALE` / `_FEMALE` | 1500 / 1200 | Plancher budget |
| `DEFAULT_COIL_LIFESPAN_DAYS` | 14 | Repli amortissement résistance |
| `NICOTINE_MG_PER_CIG` | 12 | Équivalence informative |
| `MINUTES_PER_CIG` | 11 | « Temps de vie récupéré » |
| `WORKOUT_OVERLAP_THRESHOLD` | 0.8 | Dédup séances inter-sources |
| `ADAPTIVE_TDEE_MIN_DAYS` | 21 | Fenêtre min. calibration §5.7 |