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>
60 KiB
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(idBigIntegeridentity 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_idafin 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=Falseupdated_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éfautEurope/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
timestamptzpar jour (repas, pesées, recharges), on convertit d'abord en local :date_local = (ts AT TIME ZONE 'UTC') AT TIME ZONE user.timezonepuis::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)(jamaisFloatpour 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 calculeexternal_id = sha256(ligne_normalisée)[:32].- Contrainte : index unique partiel (les
UniqueConstraintclassiques 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_settingsclé/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 deuxNULLcomme distincts — utiliserpostgresql_nulls_not_distinct=Truesur cette contrainte, disponible en PG15+, pour quebrand NULLdé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 :
total_kcalde 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) ;- sinon, si seulement
active_kcalest présent :TDEE = BMR + active_kcal(léger sous-comptage assumé : la thermogenèse alimentaire n'est pas incluse — documenté dans l'UI) ; - 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_kgvient 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)avecweeks_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 avertissementrate_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_{i−1} + alpha_eff × (weight_i − trend_{i−1})
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 — proposertdee_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_componentsn'a pas deuser_id: l'appartenance passe parmixes; les jointures de contrôle d'accès vérifientmixes.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 undaily_totalpour un jour, il remplace la somme desrefillde 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 dansweight_entries,activity_daily(source=health_connect, conflit sur(user_id, date, source)),workouts,water_entriesavecexternal_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 |