# 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__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="", 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_{i−1} + alpha_eff × (weight_i − trend_{i−1}) 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 |