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>
1147 lines
60 KiB
Markdown
1147 lines
60 KiB
Markdown
# LifeTrack — Modèle de données & calculs : modules SANTÉ / FITNESS / NUTRITION et VAPE
|
||
|
||
> **Statut** : spécification de conception, prête pour implémentation.
|
||
> **Portée** : tables SQL (types SQLAlchemy 2.0 / PostgreSQL 16), contraintes, index, stratégies de fusion/déduplication, formules de calcul exactes avec pseudocode, et endpoints API.
|
||
> **Hors portée** : module FINANCE, framework de connecteurs/importeurs (documents séparés) — seuls les points de contact sont mentionnés ici.
|
||
> **Langue** : prose en français, identifiants de code en anglais (conforme aux conventions projet).
|
||
|
||
---
|
||
|
||
## 1. Conventions transverses
|
||
|
||
Ces conventions s'appliquent à **toutes** les tables décrites dans ce document. Les agents d'implémentation doivent les respecter sans exception.
|
||
|
||
### 1.1 Multi-utilisateur
|
||
|
||
- Toutes les tables de données portent une colonne `user_id` : `ForeignKey("users.id", ondelete="CASCADE")`, `nullable=False`.
|
||
- La table `users` (id `BigInteger` identity PK, email, password_hash, created_at…) est définie dans le document d'architecture auth ; ici on la considère acquise.
|
||
- **Tous les index composites commencent par `user_id`** afin que chaque requête filtrée par utilisateur soit couverte.
|
||
- Aucune donnée n'est jamais lue sans filtre `user_id == current_user.id` (imposé au niveau des repositories/services).
|
||
|
||
### 1.2 Clés primaires et horodatage
|
||
|
||
- PK : `id = mapped_column(BigInteger, Identity(), primary_key=True)` sur toutes les tables (pas d'UUID : app mono-instance, IDs séquentiels suffisants et plus compacts pour les index).
|
||
- Colonnes d'audit sur toutes les tables :
|
||
- `created_at : DateTime(timezone=True), server_default=func.now(), nullable=False`
|
||
- `updated_at : DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False`
|
||
- Ces colonnes ne sont **pas répétées** dans les tableaux ci-dessous pour alléger la lecture, mais elles sont obligatoires.
|
||
|
||
### 1.3 Temps et fuseaux horaires
|
||
|
||
Règle projet : **stockage UTC, affichage Europe/Paris**.
|
||
|
||
- Tout instant précis : `DateTime(timezone=True)` (⇒ `timestamptz`), stocké en UTC.
|
||
- Les colonnes de type `Date` (`activity_daily.date`, `liquid_entries.entry_date`, etc.) représentent un **jour civil local** (fuseau du profil utilisateur, défaut `Europe/Paris`). C'est une décision volontaire : « les pas du 12 août » sont un concept local, pas UTC.
|
||
- **Convention d'agrégation journalière** : pour agréger des `timestamptz` par jour (repas, pesées, recharges), on convertit d'abord en local : `date_local = (ts AT TIME ZONE 'UTC') AT TIME ZONE user.timezone` puis `::date`. En Python : `ts.astimezone(user_tz).date()`. Toutes les séries « par jour » de ce document utilisent cette convention.
|
||
|
||
### 1.4 Types numériques
|
||
|
||
- Mesures corporelles, volumes, prix : `Numeric(p, s)` (jamais `Float` pour l'argent ni les poids).
|
||
- Compteurs entiers (pas, ml arrondis non — ml sont décimaux) : `Integer` / `BigInteger`.
|
||
- Calories : `Numeric(7, 1)` (permet les décimales des exports Foodvisor tout en restant compact).
|
||
|
||
### 1.5 Provenance des données (`source`) et déduplication
|
||
|
||
Le framework de connecteurs doit pouvoir ajouter des sources sans migration SQL. La colonne `source` est donc un `String(32)` **contrôlé côté application** (registre Python), et non un enum PostgreSQL.
|
||
|
||
Valeurs initiales du registre `DataSource` (module `app/core/sources.py`) :
|
||
|
||
```python
|
||
class DataSource(str, enum.Enum):
|
||
MANUAL = "manual" # saisie UI
|
||
CSV_IMPORT = "csv_import" # import fichier générique
|
||
HEALTH_CONNECT = "health_connect" # push app compagnon Android
|
||
FOODVISOR = "foodvisor" # export Foodvisor
|
||
FITSHOW = "fitshow" # export/synchro FitShow (tapis)
|
||
API = "api" # ingestion REST générique
|
||
```
|
||
|
||
**Clé de déduplication standard** : toute table alimentée par des connecteurs porte :
|
||
|
||
- `external_id : String(128), nullable=True` — identifiant chez la source (UUID Health Connect, id de ligne d'export, hash de ligne CSV…). Pour les imports CSV sans identifiant natif, l'importeur calcule `external_id = sha256(ligne_normalisée)[:32]`.
|
||
- Contrainte : **index unique partiel** (les `UniqueConstraint` classiques laissent passer les doublons de saisie manuelle où `external_id IS NULL`) :
|
||
|
||
```python
|
||
Index(
|
||
"uq_<table>_user_source_extid",
|
||
"user_id", "source", "external_id",
|
||
unique=True,
|
||
postgresql_where=text("external_id IS NOT NULL"),
|
||
)
|
||
```
|
||
|
||
- Comportement des importeurs : `INSERT ... ON CONFLICT (user_id, source, external_id) DO UPDATE` (upsert idempotent) ⇒ ré-importer le même fichier ne crée jamais de doublon.
|
||
- `raw : JSONB, nullable=True` — charge utile brute de la source (payload Health Connect, ligne CSV parsée…). Conservée pour ré-interprétation future ; jamais utilisée dans les calculs.
|
||
|
||
### 1.6 Enums PostgreSQL natifs
|
||
|
||
Utilisés uniquement pour les domaines **stables** (une migration Alembic par ajout de valeur est acceptable) : `sex`, `activity_level`, `meal_type`, `goal_mode`, `goal_status`, `sport_type`, `product_kind`, `size_unit`, `liquid_entry_kind`. Déclaration SQLAlchemy : `Enum(PyEnum, name="<snake_name>", native_enum=True)`.
|
||
|
||
### 1.7 Suppression
|
||
|
||
Suppression **physique** (hard delete) pour toutes les tables de ce document — pas de soft delete. Exceptions : `products` et `mixes` utilisent un drapeau `is_archived` car ils sont référencés par l'historique (`purchases`, `coil_changes`, `liquid_entries`) ; leur suppression physique n'est autorisée que si aucune référence n'existe (`ondelete="RESTRICT"`).
|
||
|
||
---
|
||
|
||
## 2. Vue d'ensemble (diagramme ER)
|
||
|
||
```mermaid
|
||
erDiagram
|
||
users ||--|| user_profile : "1-1"
|
||
users ||--o{ weight_entries : ""
|
||
users ||--o{ body_measurements : ""
|
||
users ||--o{ activity_daily : ""
|
||
users ||--o{ workouts : ""
|
||
users ||--o{ goals : ""
|
||
users ||--o{ food_entries : ""
|
||
users ||--o{ food_favorites : ""
|
||
users ||--o{ water_entries : ""
|
||
users ||--|| vape_settings : "1-1"
|
||
users ||--o{ liquid_entries : ""
|
||
users ||--o{ products : ""
|
||
users ||--o{ mixes : ""
|
||
users ||--o{ coil_changes : ""
|
||
users ||--o{ purchases : ""
|
||
mixes ||--o{ mix_components : ""
|
||
products ||--o{ mix_components : ""
|
||
products ||--o{ coil_changes : ""
|
||
products ||--o{ purchases : ""
|
||
mixes ||--o{ liquid_entries : "mix utilisé"
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Module SANTÉ / FITNESS
|
||
|
||
### 3.1 Table `user_profile`
|
||
|
||
Une ligne par utilisateur (relation 1-1 avec `users`). Contient les paramètres physiologiques nécessaires aux calculs BMR/TDEE.
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Défaut | Description |
|
||
|---|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK |
|
||
| `user_id` | `BigInteger, ForeignKey("users.id", ondelete="CASCADE")` | non | — | Propriétaire |
|
||
| `height_cm` | `Numeric(4, 1)` | non | — | Taille en cm (ex. 178.5) |
|
||
| `sex` | `Enum(Sex, name="sex")` | non | — | `male` / `female` / `other` |
|
||
| `birthdate` | `Date` | non | — | Date de naissance (âge dérivé) |
|
||
| `activity_level` | `Enum(ActivityLevel, name="activity_level")` | non | `'sedentary'` | Niveau d'activité habituel |
|
||
| `timezone` | `String(64)` | non | `'Europe/Paris'` | Fuseau IANA de l'utilisateur |
|
||
| `water_goal_ml` | `Integer` | oui | `2000` | Objectif hydratation quotidien |
|
||
| `calorie_floor_kcal` | `Integer` | oui | `NULL` | Plancher calorique personnalisé (sinon défaut par sexe, cf. §5.3) |
|
||
|
||
Enums :
|
||
|
||
```python
|
||
class Sex(str, enum.Enum):
|
||
MALE = "male"
|
||
FEMALE = "female"
|
||
OTHER = "other" # formule BMR : moyenne homme/femme (cf. §5.1)
|
||
|
||
class ActivityLevel(str, enum.Enum):
|
||
SEDENTARY = "sedentary" # facteur 1.2
|
||
LIGHT = "light" # 1.375
|
||
MODERATE = "moderate" # 1.55
|
||
ACTIVE = "active" # 1.725
|
||
VERY_ACTIVE = "very_active" # 1.9
|
||
```
|
||
|
||
Contraintes / index :
|
||
- `UniqueConstraint("user_id", name="uq_user_profile_user")` — une seule ligne par utilisateur.
|
||
- `CheckConstraint("height_cm > 0 AND height_cm < 300", name="ck_user_profile_height")`.
|
||
|
||
### 3.2 Table `weight_entries`
|
||
|
||
Pesées. Plusieurs pesées par jour possibles (matin/soir) ; les calculs de tendance utilisent la **première pesée du jour local** (convention balance à jeun).
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Défaut | Description |
|
||
|---|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | — | |
|
||
| `measured_at` | `DateTime(timezone=True)` | non | — | Instant de pesée (UTC) |
|
||
| `weight_kg` | `Numeric(5, 2)` | non | — | Poids en kg (ex. 92.40) |
|
||
| `body_fat_pct` | `Numeric(4, 1)` | oui | — | % masse grasse (balance impédancemètre) |
|
||
| `muscle_mass_kg` | `Numeric(5, 2)` | oui | — | Masse musculaire si fournie |
|
||
| `water_pct` | `Numeric(4, 1)` | oui | — | % eau si fourni |
|
||
| `source` | `String(32)` | non | `'manual'` | Registre `DataSource` |
|
||
| `external_id` | `String(128)` | oui | — | Dédup connecteurs (cf. §1.5) |
|
||
| `note` | `String(255)` | oui | — | Commentaire libre |
|
||
| `raw` | `JSONB` | oui | — | Payload source |
|
||
|
||
Contraintes / index :
|
||
- `Index("ix_weight_entries_user_measured", "user_id", "measured_at")` — requêtes de séries.
|
||
- Index unique partiel `uq_weight_entries_user_source_extid` (§1.5).
|
||
- `UniqueConstraint("user_id", "measured_at", "source", name="uq_weight_entries_user_ts_source")` — bloque le double-clic de saisie et les doubles imports sans external_id.
|
||
- `CheckConstraint("weight_kg > 20 AND weight_kg < 400", name="ck_weight_entries_range")`.
|
||
|
||
### 3.3 Table `body_measurements`
|
||
|
||
Mensurations. Une ligne = une séance de mesure ; toutes les colonnes de mesure sont optionnelles (l'utilisateur mesure ce qu'il veut).
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Description |
|
||
|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
|
||
| `measured_at` | `DateTime(timezone=True)` | non | Instant de mesure |
|
||
| `neck_cm` | `Numeric(4, 1)` | oui | Cou |
|
||
| `chest_cm` | `Numeric(4, 1)` | oui | Poitrine |
|
||
| `waist_cm` | `Numeric(4, 1)` | oui | Taille (nombril) — sert à la formule Navy |
|
||
| `hips_cm` | `Numeric(4, 1)` | oui | Hanches |
|
||
| `biceps_left_cm` | `Numeric(4, 1)` | oui | Biceps gauche |
|
||
| `biceps_right_cm` | `Numeric(4, 1)` | oui | Biceps droit |
|
||
| `thigh_left_cm` | `Numeric(4, 1)` | oui | Cuisse gauche |
|
||
| `thigh_right_cm` | `Numeric(4, 1)` | oui | Cuisse droite |
|
||
| `calf_left_cm` | `Numeric(4, 1)` | oui | Mollet gauche |
|
||
| `calf_right_cm` | `Numeric(4, 1)` | oui | Mollet droit |
|
||
| `source` | `String(32)` | non (déf. `'manual'`) | |
|
||
| `external_id` | `String(128)` | oui | Dédup |
|
||
| `note` | `String(255)` | oui | |
|
||
|
||
Contraintes / index :
|
||
- `Index("ix_body_measurements_user_measured", "user_id", "measured_at")`.
|
||
- Index unique partiel `uq_body_measurements_user_source_extid`.
|
||
|
||
**Bonus dérivé (formule US Navy, % masse grasse estimé)** — calculé à la volée si `waist_cm`, `neck_cm` (et `hips_cm` pour les femmes) sont présents, avec `h = height_cm` :
|
||
|
||
```
|
||
homme : bf% = 495 / (1.0324 − 0.19077·log10(waist − neck) + 0.15456·log10(h)) − 450
|
||
femme : bf% = 495 / (1.29579 − 0.35004·log10(waist + hips − neck) + 0.22100·log10(h)) − 450
|
||
```
|
||
|
||
### 3.4 Table `activity_daily`
|
||
|
||
Agrégats d'activité **par jour local et par source**. On stocke une ligne par `(user, date, source)` ; la fusion inter-sources est faite en lecture (cf. §3.5). Ne jamais écraser la ligne d'une source par une autre.
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Description |
|
||
|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
|
||
| `date` | `Date` | non | Jour civil local (Europe/Paris) |
|
||
| `steps` | `Integer` | oui | Nombre de pas |
|
||
| `active_kcal` | `Numeric(7, 1)` | oui | Calories actives (hors métabolisme de base) |
|
||
| `total_kcal` | `Numeric(7, 1)` | oui | Dépense totale du jour (TDEE mesuré) si la source la fournit |
|
||
| `distance_m` | `Integer` | oui | Distance en mètres |
|
||
| `active_minutes` | `Integer` | oui | Minutes actives si fournies |
|
||
| `floors` | `Integer` | oui | Étages si fournis |
|
||
| `source` | `String(32)` | non | Registre `DataSource` |
|
||
| `external_id` | `String(128)` | oui | Dédup (souvent inutile ici : la clé naturelle suffit) |
|
||
| `raw` | `JSONB` | oui | Payload source |
|
||
|
||
Contraintes / index :
|
||
- `UniqueConstraint("user_id", "date", "source", name="uq_activity_daily_user_date_source")` — **clé de dédup naturelle** : un connecteur upserte sur ce triplet (`ON CONFLICT DO UPDATE` — Health Connect re-pousse le même jour plusieurs fois dans la journée avec des valeurs croissantes).
|
||
- `Index("ix_activity_daily_user_date", "user_id", "date")`.
|
||
- `CheckConstraint("steps IS NULL OR steps >= 0", name="ck_activity_daily_steps")`.
|
||
|
||
### 3.5 Fusion multi-sources de `activity_daily`
|
||
|
||
**Problème** : le même jour peut exister via `health_connect`, `fitshow`, `csv_import` et `manual`. **Stratégie : priorité par source, champ par champ** (pas ligne par ligne — une source peut fournir les pas, une autre les calories).
|
||
|
||
Ordre de priorité par défaut (du plus prioritaire au moins prioritaire), constante `ACTIVITY_SOURCE_PRIORITY` dans `app/health/merge.py` :
|
||
|
||
```python
|
||
ACTIVITY_SOURCE_PRIORITY = [
|
||
"manual", # une correction manuelle gagne toujours
|
||
"health_connect", # agrégateur Android : donnée la plus complète/fiable
|
||
"fitshow", # spécifique tapis : fiable pour distance/kcal du tapis mais partiel
|
||
"csv_import",
|
||
"api",
|
||
]
|
||
```
|
||
|
||
Algorithme de fusion (exécuté en lecture par le service, pas de table matérialisée en v1) :
|
||
|
||
```python
|
||
def merge_activity_day(rows: list[ActivityDaily]) -> MergedActivity:
|
||
"""rows = toutes les lignes (user, date) triées par priorité croissante d'index
|
||
dans ACTIVITY_SOURCE_PRIORITY (les sources inconnues vont en dernier)."""
|
||
merged = MergedActivity(date=rows[0].date)
|
||
for field in ("steps", "active_kcal", "total_kcal", "distance_m",
|
||
"active_minutes", "floors"):
|
||
for row in rows: # ordre = priorité décroissante
|
||
value = getattr(row, field)
|
||
if value is not None:
|
||
setattr(merged, field, value)
|
||
merged.field_sources[field] = row.source # traçabilité UI
|
||
break
|
||
return merged
|
||
```
|
||
|
||
Points importants :
|
||
- La réponse API expose `field_sources` (dict champ → source retenue) pour que l'UI affiche l'origine (« Pas : Health Connect »).
|
||
- **On n'additionne jamais deux sources** (risque de double comptage : FitShow est déjà agrégé dans Health Connect si l'app y écrit).
|
||
- L'ordre est stocké en dur en v1 ; prévoir une table `user_settings` clé/valeur en v2 si l'utilisateur veut le personnaliser.
|
||
|
||
### 3.6 Table `workouts`
|
||
|
||
Séances de sport (tapis FitShow, autres sports, saisie manuelle).
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Description |
|
||
|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
|
||
| `started_at` | `DateTime(timezone=True)` | non | Début |
|
||
| `ended_at` | `DateTime(timezone=True)` | non | Fin |
|
||
| `sport_type` | `Enum(SportType, name="sport_type")` | non | Type de sport |
|
||
| `sport_label` | `String(100)` | oui | Précision libre quand `sport_type='other'` |
|
||
| `kcal` | `Numeric(7, 1)` | oui | Calories brûlées annoncées |
|
||
| `distance_m` | `Integer` | oui | Distance |
|
||
| `steps` | `Integer` | oui | Pas de la séance si fournis |
|
||
| `avg_hr` | `Integer` | oui | FC moyenne (bpm) |
|
||
| `max_hr` | `Integer` | oui | FC max (bpm) |
|
||
| `avg_speed_kmh` | `Numeric(4, 1)` | oui | Vitesse moyenne (tapis) |
|
||
| `elevation_m` | `Integer` | oui | Dénivelé |
|
||
| `is_hidden` | `Boolean` | non (déf. `false`) | Marqué doublon inter-sources (cf. ci-dessous) |
|
||
| `source` | `String(32)` | non | |
|
||
| `external_id` | `String(128)` | oui | Dédup |
|
||
| `note` | `String(255)` | oui | |
|
||
| `raw` | `JSONB` | oui | Payload source complet (FitShow : splits, courbe FC…) |
|
||
|
||
```python
|
||
class SportType(str, enum.Enum):
|
||
TREADMILL_WALK = "treadmill_walk"
|
||
TREADMILL_RUN = "treadmill_run"
|
||
WALKING = "walking"
|
||
RUNNING = "running"
|
||
CYCLING = "cycling"
|
||
SWIMMING = "swimming"
|
||
STRENGTH = "strength"
|
||
HIIT = "hiit"
|
||
YOGA = "yoga"
|
||
HIKING = "hiking"
|
||
OTHER = "other"
|
||
```
|
||
|
||
Contraintes / index :
|
||
- `Index("ix_workouts_user_started", "user_id", "started_at")`.
|
||
- Index unique partiel `uq_workouts_user_source_extid` (§1.5) — dédup principal.
|
||
- `CheckConstraint("ended_at > started_at", name="ck_workouts_duration")`.
|
||
|
||
**Dédup inter-sources (chevauchement)** : une séance tapis peut arriver via FitShow **et** Health Connect avec des `external_id` différents. Règle appliquée par le service à l'insertion :
|
||
|
||
```python
|
||
def flag_overlapping_duplicates(new_wo):
|
||
overlaps = find_workouts(
|
||
user_id=new_wo.user_id,
|
||
started_at < new_wo.ended_at, ended_at > new_wo.started_at,
|
||
source != new_wo.source, is_hidden == False)
|
||
for other in overlaps:
|
||
inter = overlap_seconds(new_wo, other)
|
||
shorter = min(duration(new_wo), duration(other))
|
||
if inter / shorter >= 0.8: # 80 % de recouvrement
|
||
loser = lower_priority(new_wo, other, ACTIVITY_SOURCE_PRIORITY)
|
||
loser.is_hidden = True # conservé mais exclu des stats
|
||
```
|
||
|
||
Les stats et listes excluent `is_hidden = true` par défaut (`?include_hidden=true` pour audit).
|
||
|
||
### 3.7 Table `goals`
|
||
|
||
Objectifs de poids. Historisés (un objectif atteint/abandonné reste en base) ; **un seul objectif actif** à la fois.
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Description |
|
||
|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
|
||
| `mode` | `Enum(GoalMode, name="goal_mode")` | non | Comment le budget est dérivé (cf. §5.3) |
|
||
| `start_date` | `Date` | non | Début de l'objectif (jour local) |
|
||
| `start_weight_kg` | `Numeric(5, 2)` | non | Poids (tendance) au démarrage — figé à la création |
|
||
| `target_weight_kg` | `Numeric(5, 2)` | non | Poids cible |
|
||
| `target_date` | `Date` | oui | Échéance (requis si `mode='target_date'`) |
|
||
| `weekly_rate_kg` | `Numeric(4, 2)` | oui | Rythme visé en kg/semaine, **positif = perte** (requis si `mode='weekly_rate'`) |
|
||
| `status` | `Enum(GoalStatus, name="goal_status")` | non (déf. `'active'`) | |
|
||
| `note` | `String(255)` | oui | |
|
||
|
||
```python
|
||
class GoalMode(str, enum.Enum):
|
||
WEEKLY_RATE = "weekly_rate" # l'utilisateur fixe kg/semaine → budget dérivé
|
||
TARGET_DATE = "target_date" # l'utilisateur fixe la date → rythme dérivé
|
||
MAINTAIN = "maintain" # maintien : budget = TDEE
|
||
|
||
class GoalStatus(str, enum.Enum):
|
||
ACTIVE = "active"
|
||
COMPLETED = "completed"
|
||
ABANDONED = "abandoned"
|
||
```
|
||
|
||
Contraintes / index :
|
||
- Index unique partiel : `Index("uq_goals_user_active", "user_id", unique=True, postgresql_where=text("status = 'active'"))` — un seul objectif actif.
|
||
- `CheckConstraint("weekly_rate_kg IS NULL OR (weekly_rate_kg > -1.01 AND weekly_rate_kg <= 1.5)", name="ck_goals_rate_sane")` (négatif = prise de masse autorisée, bornée).
|
||
- `CheckConstraint("mode <> 'target_date' OR target_date IS NOT NULL", name="ck_goals_target_date")`.
|
||
- `CheckConstraint("mode <> 'weekly_rate' OR weekly_rate_kg IS NOT NULL", name="ck_goals_weekly_rate")`.
|
||
|
||
---
|
||
|
||
## 4. Module NUTRITION
|
||
|
||
### 4.1 Table `food_entries`
|
||
|
||
Journal alimentaire. Alimenté par l'export Foodvisor (CSV/JSON), la saisie manuelle et les favoris.
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Description |
|
||
|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
|
||
| `eaten_at` | `DateTime(timezone=True)` | non | Instant de consommation |
|
||
| `meal` | `Enum(MealType, name="meal_type")` | non | Repas |
|
||
| `name` | `String(200)` | non | Nom de l'aliment (FR, tel que saisi/importé) |
|
||
| `brand` | `String(100)` | oui | Marque |
|
||
| `quantity` | `Numeric(8, 2)` | non | Quantité consommée |
|
||
| `unit` | `String(20)` | non (déf. `'g'`) | Unité libre normalisée : `g`, `ml`, `portion`, `piece` |
|
||
| `kcal` | `Numeric(7, 1)` | non | Calories **de la quantité consommée** (pas pour 100 g) |
|
||
| `protein_g` | `Numeric(6, 1)` | oui | Protéines (quantité consommée) |
|
||
| `carbs_g` | `Numeric(6, 1)` | oui | Glucides |
|
||
| `fat_g` | `Numeric(6, 1)` | oui | Lipides |
|
||
| `fiber_g` | `Numeric(6, 1)` | oui | Fibres |
|
||
| `sugar_g` | `Numeric(6, 1)` | oui | Sucres (présent dans exports Foodvisor) |
|
||
| `sat_fat_g` | `Numeric(6, 1)` | oui | Acides gras saturés |
|
||
| `sodium_mg` | `Numeric(8, 1)` | oui | Sodium |
|
||
| `source` | `String(32)` | non (déf. `'manual'`) | |
|
||
| `external_id` | `String(128)` | oui | Dédup Foodvisor / CSV |
|
||
| `raw` | `JSONB` | oui | Ligne d'export brute |
|
||
|
||
```python
|
||
class MealType(str, enum.Enum):
|
||
BREAKFAST = "breakfast" # petit-déjeuner
|
||
LUNCH = "lunch" # déjeuner
|
||
DINNER = "dinner" # dîner
|
||
SNACK = "snack" # collation
|
||
```
|
||
|
||
Contraintes / index :
|
||
- `Index("ix_food_entries_user_eaten", "user_id", "eaten_at")`.
|
||
- Index unique partiel `uq_food_entries_user_source_extid` (§1.5).
|
||
- `CheckConstraint("kcal >= 0 AND quantity > 0", name="ck_food_entries_positive")`.
|
||
|
||
**Toutes les valeurs nutritionnelles sont absolues** (pour la quantité saisie), jamais « pour 100 g » : c'est le format des exports Foodvisor et cela évite toute ambiguïté d'agrégation. La conversion pour-100g → absolu est la responsabilité de l'importeur.
|
||
|
||
### 4.2 Table `food_favorites`
|
||
|
||
Aliments favoris pour la saisie rapide. Les **récents** ne sont pas une table : c'est une requête sur `food_entries` (cf. endpoint `/nutrition/recent`). Les macros sont stockées **pour la quantité par défaut** (même convention absolue que `food_entries`).
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Description |
|
||
|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
|
||
| `name` | `String(200)` | non | |
|
||
| `brand` | `String(100)` | oui | |
|
||
| `default_quantity` | `Numeric(8, 2)` | non | Quantité par défaut proposée |
|
||
| `unit` | `String(20)` | non (déf. `'g'`) | |
|
||
| `kcal` | `Numeric(7, 1)` | non | Pour `default_quantity` |
|
||
| `protein_g` | `Numeric(6, 1)` | oui | |
|
||
| `carbs_g` | `Numeric(6, 1)` | oui | |
|
||
| `fat_g` | `Numeric(6, 1)` | oui | |
|
||
| `fiber_g` | `Numeric(6, 1)` | oui | |
|
||
| `default_meal` | `Enum(MealType)` | oui | Pré-sélection du repas |
|
||
| `use_count` | `Integer` | non (déf. `0`) | Incrémenté à chaque utilisation |
|
||
| `last_used_at` | `DateTime(timezone=True)` | oui | Pour trier « favoris récents » |
|
||
|
||
Contraintes / index :
|
||
- `UniqueConstraint("user_id", "name", "brand", name="uq_food_favorites_user_name_brand")` (NB : PostgreSQL considère deux `NULL` comme distincts — utiliser `postgresql_nulls_not_distinct=True` sur cette contrainte, disponible en PG15+, pour que `brand NULL` dédoublonne aussi).
|
||
- `Index("ix_food_favorites_user_usage", "user_id", "use_count")`.
|
||
|
||
Lors de la saisie via un favori : le service copie les valeurs dans `food_entries` en les **proratisant** si l'utilisateur modifie la quantité (`kcal_entry = kcal_fav × quantity_entry / default_quantity`), puis incrémente `use_count` et met à jour `last_used_at`.
|
||
|
||
### 4.3 Table `water_entries`
|
||
|
||
Hydratation (optionnelle mais spécifiée pour l'implémentation directe).
|
||
|
||
| Colonne | Type SQLAlchemy | Null | Description |
|
||
|---|---|---|---|
|
||
| `id` | `BigInteger, Identity(), primary_key=True` | non | PK |
|
||
| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | |
|
||
| `drunk_at` | `DateTime(timezone=True)` | non | Instant |
|
||
| `volume_ml` | `Integer` | non | Volume (ex. 250) |
|
||
| `source` | `String(32)` | non (déf. `'manual'`) | |
|
||
| `external_id` | `String(128)` | oui | Dédup Health Connect (hydration records) |
|
||
|
||
Contraintes / index :
|
||
- `Index("ix_water_entries_user_drunk", "user_id", "drunk_at")`.
|
||
- Index unique partiel `uq_water_entries_user_source_extid`.
|
||
- `CheckConstraint("volume_ml > 0 AND volume_ml <= 5000", name="ck_water_entries_volume")`.
|
||
|
||
---
|
||
|
||
## 5. Calculs SANTÉ / NUTRITION (formules exactes + pseudocode)
|
||
|
||
Tous les calculs vivent dans `app/health/calculations.py` (fonctions pures, testables unitairement) et sont orchestrés par les endpoints de stats. Constante centrale :
|
||
|
||
```python
|
||
KCAL_PER_KG_FAT = 7700 # 1 kg de masse corporelle ≈ 7700 kcal
|
||
EMA_ALPHA = 0.1 # lissage tendance poids (Hacker's Diet)
|
||
MIN_SLOPE_KG_PER_DAY = 0.005 # sous ce seuil, pente considérée nulle
|
||
```
|
||
|
||
### 5.1 BMR — Mifflin-St Jeor
|
||
|
||
Entrées : `weight_kg` (poids **tendance** courant, cf. §5.5 ; repli : dernière pesée), `height_cm`, `age` (années révolues à la date du calcul), `sex`.
|
||
|
||
```
|
||
homme : BMR = 10·weight_kg + 6.25·height_cm − 5·age + 5
|
||
femme : BMR = 10·weight_kg + 6.25·height_cm − 5·age − 161
|
||
other : BMR = 10·weight_kg + 6.25·height_cm − 5·age − 78 # moyenne des deux
|
||
```
|
||
|
||
```python
|
||
def bmr_mifflin(weight_kg: float, height_cm: float, age: int, sex: Sex) -> float:
|
||
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
|
||
offset = {Sex.MALE: 5, Sex.FEMALE: -161, Sex.OTHER: -78}[sex]
|
||
return base + offset
|
||
```
|
||
|
||
### 5.2 TDEE (dépense énergétique totale) — estimé vs mesuré
|
||
|
||
Facteurs d'activité (`ACTIVITY_FACTORS`) :
|
||
|
||
| `activity_level` | Facteur |
|
||
|---|---|
|
||
| `sedentary` | 1.2 |
|
||
| `light` | 1.375 |
|
||
| `moderate` | 1.55 |
|
||
| `active` | 1.725 |
|
||
| `very_active` | 1.9 |
|
||
|
||
**TDEE effectif d'un jour donné** — ordre de préférence :
|
||
|
||
1. `total_kcal` de l'activité fusionnée du jour (mesure directe de l'appareil/Health Connect) — si présent et plausible (`> 0.8 × BMR`, garde-fou contre les jours partiels) ;
|
||
2. sinon, si seulement `active_kcal` est présent : `TDEE = BMR + active_kcal` (léger sous-comptage assumé : la thermogenèse alimentaire n'est pas incluse — documenté dans l'UI) ;
|
||
3. sinon : `TDEE = BMR × activity_factor`.
|
||
|
||
```python
|
||
def tdee_effective(day: date, profile, merged_activity) -> TdeeResult:
|
||
bmr = bmr_mifflin(trend_weight(day), profile.height_cm,
|
||
age_on(day, profile.birthdate), profile.sex)
|
||
a = merged_activity # peut être None
|
||
if a and a.total_kcal and a.total_kcal > 0.8 * bmr:
|
||
return TdeeResult(kcal=float(a.total_kcal), method="measured_total")
|
||
if a and a.active_kcal is not None:
|
||
return TdeeResult(kcal=bmr + float(a.active_kcal), method="bmr_plus_active")
|
||
return TdeeResult(kcal=bmr * ACTIVITY_FACTORS[profile.activity_level],
|
||
method="estimated")
|
||
```
|
||
|
||
La réponse API renvoie toujours `method` pour que l'UI signale « mesuré » vs « estimé ». Pour les affichages agrégés (budget), on utilise aussi `tdee_smoothed = moyenne mobile 7 jours` de `tdee_effective` afin d'éviter qu'un jour très actif gonfle le budget du lendemain.
|
||
|
||
### 5.3 Budget calorique quotidien
|
||
|
||
Déficit quotidien visé à partir de l'objectif actif :
|
||
|
||
```
|
||
deficit_kcal_day = weekly_rate_kg × 7700 / 7 = weekly_rate_kg × 1100
|
||
budget_kcal_day = TDEE_effectif(jour) − deficit_kcal_day
|
||
```
|
||
|
||
Selon `goals.mode` :
|
||
|
||
- **`weekly_rate`** : `weekly_rate_kg` vient de l'objectif (positif = perte ⇒ déficit ; négatif = prise ⇒ surplus).
|
||
- **`target_date`** : le rythme est **recalculé chaque jour** à partir de la tendance courante :
|
||
`weekly_rate_kg = (trend_weight_now − target_weight_kg) / max(weeks_remaining, 1)`
|
||
avec `weeks_remaining = (target_date − today).days / 7`. Rythme borné à `[−0.5, +1.0]` kg/semaine ; si le rythme requis dépasse 1.0 kg/sem, l'API renvoie un avertissement `rate_clamped=true` (l'échéance n'est plus tenable).
|
||
- **`maintain`** : `deficit = 0`, `budget = TDEE`.
|
||
|
||
**Plancher de sécurité** : `budget = max(budget, floor)` avec `floor = profile.calorie_floor_kcal` si défini, sinon `1500` (homme) / `1200` (femme, `other`). Si le plancher est appliqué, l'API renvoie `floor_applied=true`.
|
||
|
||
```python
|
||
def daily_budget(day, profile, goal, tdee_smoothed_kcal) -> BudgetResult:
|
||
if goal is None or goal.mode == GoalMode.MAINTAIN:
|
||
rate = 0.0
|
||
elif goal.mode == GoalMode.WEEKLY_RATE:
|
||
rate = float(goal.weekly_rate_kg)
|
||
else: # TARGET_DATE
|
||
weeks_left = max(((goal.target_date - day).days) / 7, 1.0)
|
||
rate = (trend_weight(day) - float(goal.target_weight_kg)) / weeks_left
|
||
rate = clamp(rate, -0.5, 1.0)
|
||
deficit = rate * KCAL_PER_KG_FAT / 7 # = rate × 1100
|
||
budget = tdee_smoothed_kcal - deficit
|
||
floor = profile.calorie_floor_kcal or (1500 if profile.sex == Sex.MALE else 1200)
|
||
return BudgetResult(kcal=max(budget, floor), deficit_target=deficit,
|
||
floor_applied=budget < floor)
|
||
```
|
||
|
||
### 5.4 Bilan énergétique quotidien
|
||
|
||
Pour chaque jour local `d` :
|
||
|
||
```
|
||
intake_kcal(d) = Σ food_entries.kcal où jour_local(eaten_at) = d
|
||
balance(d) = intake_kcal(d) − TDEE_effectif(d) # négatif = déficit
|
||
vs_budget(d) = intake_kcal(d) − budget_kcal(d) # négatif = sous le budget
|
||
```
|
||
|
||
Les jours **sans aucune** `food_entry` sont renvoyés avec `intake=null, balance=null` (jour non tracké ≠ jeûne) et **exclus** des cumuls du §5.7.
|
||
|
||
### 5.5 Tendance de poids — EMA (Hacker's Diet) + pente par régression
|
||
|
||
**Série d'entrée** : une valeur par jour local = **première pesée du jour** (min `measured_at` du jour). Jours sans pesée : pas de point (la formule gère les trous).
|
||
|
||
**EMA avec correction des trous** (équivalent à appliquer l'EMA quotidienne en maintenant la tendance les jours sans mesure) :
|
||
|
||
```
|
||
alpha = 0.1
|
||
alpha_eff = 1 − (1 − alpha)^gap_days # gap_days = jours écoulés depuis la mesure précédente
|
||
trend_i = trend_{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 |
|