Files
lifetrack/docs/design/datamodel-health-vape.md
T
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

60 KiB
Raw Blame History

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) :

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) :
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)

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 :

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 :

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) :

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…)
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 :

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
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
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 :

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

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
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
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) :

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
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
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) :

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

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
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 :

{
  "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