Fix 20+ defects found by real Docker/PostgreSQL deployment

Première exécution réelle de la stack (build des images, PostgreSQL 16,
parcours fonctionnels en HTTP) : 28 tables, 105 index, extension pg_trgm,
et 181 assertions rejouées après correction.

Santé : filtres enum invalides renvoyaient 500 au lieu d'une erreur
française ; plan_line s'arrêtait à la fin de la fenêtre du graphique au
lieu de la date d'atteinte de l'objectif ; deficit_target_kcal était
recalculé après le plancher calorique ; « dernière pesée » affichait deux
valeurs différentes selon l'endpoint ; objectif protéines absent.

Vape : durée de vie moyenne des résistances incluait la résistance en
cours ; archiver la recette active la laissait active ; coût théorique
inventé avant la date d'arrêt ; économies projetées dans le futur ;
€/ml arrondi à 2 décimales écrasait le modèle de coût DIY.

Finances : le sankey compensait crédits et débits non catégorisés ;
rows_total excluait les lignes filtrées, faussant l'arithmétique du
rapport d'import.

Socle : les erreurs HTTP du framework fuitaient en anglais dans
l'enveloppe française ; nginx renvoyait sa page 413 HTML au lieu du JSON
français ; fins de ligne normalisées en LF.

348 tests pytest (+7), ruff, tsc et vite build au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:40:05 +02:00
co-authored by Claude Opus 5
parent d32436db4c
commit fa3db7ff22
20 changed files with 408 additions and 78 deletions
+68 -33
View File
@@ -127,8 +127,9 @@ Détails utiles :
### Imports et connecteurs (`/imports`) ### Imports et connecteurs (`/imports`)
- Assistant en trois étapes : **Fichier → Vérification → Import**, avec détection - Assistant en trois étapes : **Fichier → Vérification → Import**. Le format est détecté à
automatique du format à partir de l'en-tête du fichier. partir de l'en-tête du fichier ; pour un relevé bancaire, le profil de banque est
présélectionné à partir de ce format et reste modifiable avant l'aperçu.
- Éditeur de mappage de colonnes (séparateur, encodage, format de date, colonnes) enregistré - Éditeur de mappage de colonnes (séparateur, encodage, format de date, colonnes) enregistré
dans un profil personnel. dans un profil personnel.
- **Formats reconnus** : relevé bancaire CSV, relevé bancaire OFX, PayPal CSV, - **Formats reconnus** : relevé bancaire CSV, relevé bancaire OFX, PayPal CSV,
@@ -150,14 +151,12 @@ révocation de clés d'API par appareil), **Application** (préférences locales
**Il n'y en a pas, et ce n'est pas un oubli.** **Il n'y en a pas, et ce n'est pas un oubli.**
L'application a été développée et testée intégralement hors ligne : la suite de tests La pile a bien été construite et exécutée (voir « Ce qui est vérifié » ci-dessous), mais la
backend et la compilation du frontend passent, mais **la pile Docker Compose n'a jamais été validation s'est faite **en HTTP, pas dans un navigateur** : aucune session graphique n'a
démarrée** (le démon Docker était indisponible pendant tout le développement). Aucune été ouverte, donc aucune capture authentique n'a pu être prise. Plutôt que de publier des
capture n'a donc pu être prise sur une instance réelle. images de synthèse trompeuses, cette section restera vide jusqu'à votre premier démarrage.
Les pages à capturer en priorité : le tableau de bord, `/sante/poids`, `/vape/economies` et
Plutôt que de publier des images de synthèse trompeuses, cette section restera vide jusqu'à `/finances`.
votre premier démarrage. Une fois l'application lancée, les pages à capturer en priorité
sont : le tableau de bord, `/sante/poids`, `/vape/economies` et `/finances`.
--- ---
@@ -165,18 +164,32 @@ sont : le tableau de bord, `/sante/poids`, `/vape/economies` et `/finances`.
### Prérequis ### Prérequis
- **Docker Desktop installé ET démarré.** Vérifiez que la baleine est bien active dans la - **Docker installé et démarré** ; `docker compose version` doit répondre sans erreur
barre des tâches ; `docker compose version` doit répondre sans erreur. (Compose v2 ou plus récent). Sous Windows, vérifiez que Docker Desktop est bien actif.
- Environ 2 Go d'espace disque pour les images et la base. - Environ 2 Go d'espace disque pour les images et la base.
- Aucun autre service n'écoute sur le port choisi (80 par défaut). - Aucun autre service n'écoute sur les ports choisis (80 et 8000 par défaut).
> ⚠️ **À lire avant le premier lancement.** Le démon Docker était **hors service pendant ### Ce qui est vérifié
> toute la phase de développement** : la pile Compose n'a donc jamais été construite ni
> démarrée. Le code applicatif, lui, est validé (292 tests backend, compilation frontend). La séquence ci-dessous a été **exécutée telle quelle** sur un hôte Ubuntu (Docker Compose
> Attendez-vous éventuellement à un petit ajustement lors du tout premier `up` — le plus v5.1.1, 8 vCPU, 1,8 Go de RAM), à partir d'un volume de données vide :
> probable étant un temps de build long, ou une image de base à retélécharger. Si un
> conteneur refuse de démarrer, consultez ses journaux avec - les deux images se construisent (`lifetrack-api` ≈ 330 Mo, `lifetrack-web` ≈ 76 Mo) ;
> `docker compose logs api` avant toute autre chose. - les trois conteneurs passent `healthy` en une vingtaine de secondes ;
- le schéma est créé sur un **vrai PostgreSQL 16** (28 tables, 105 index, extension
`pg_trgm` activée) et les données survivent à un `down` / `up` ;
- l'assistant de premier démarrage, puis un parcours par module (pesée + checklist du jour,
réglages vape + recharge + économies cumulées, import d'un relevé bancaire CSV en cp1252
avec ré-import dédupliqué et annulation) ont été rejoués **en HTTP à travers nginx** :
SPA, liens profonds, proxy `/api/` et gros fichiers compris.
Ce qui n'a **pas** été vérifié : le rendu graphique dans un navigateur (voir « Captures
d'écran »). Si un conteneur refuse de démarrer, commencez par
`docker compose logs api`.
> **Hôte avec moins de 2 Go de RAM** : construisez les images **une par une** (étape 3
> ci-dessous) et, si `vite build` se fait tuer par l'OOM killer, décommentez
> `NODE_OPTIONS=--max-old-space-size=1024` dans `.env`.
### 1. Créer le fichier `.env` ### 1. Créer le fichier `.env`
@@ -229,39 +242,59 @@ Deux variables restent commentées et ne servent qu'au développement :
> `LIFETRACK_DATABASE_URL` n'est **pas** à renseigner dans `.env` : `docker-compose.yml` la > `LIFETRACK_DATABASE_URL` n'est **pas** à renseigner dans `.env` : `docker-compose.yml` la
> compose automatiquement à partir de `POSTGRES_USER`, `POSTGRES_PASSWORD` et `POSTGRES_DB`. > compose automatiquement à partir de `POSTGRES_USER`, `POSTGRES_PASSWORD` et `POSTGRES_DB`.
### 3. Lancer la pile ### 3. Construire les images
C'est la séquence exacte qui a été validée. Les deux builds sont **séparés** : le build web
(`npm ci` + `vite build`) est le seul gros consommateur de mémoire, mieux vaut ne pas le
faire tourner en même temps qu'autre chose.
```bash ```bash
docker compose up -d --build docker compose build api # ~30 s
docker compose build web # ~40 s
```
`docker compose build` (sans argument) fonctionne aussi et construit les deux à la suite.
### 4. Lancer la pile
```bash
docker compose up -d
``` ```
Trois conteneurs démarrent : `postgres` (base de données), `api` (FastAPI) et `web` Trois conteneurs démarrent : `postgres` (base de données), `api` (FastAPI) et `web`
(nginx servant le frontend compilé et relayant `/api/` vers l'API). Le premier build prend (nginx servant le frontend compilé et relayant `/api/` vers l'API). Depuis un volume vide,
plusieurs minutes. il faut une vingtaine de secondes pour que les trois soient `healthy`.
Suivre le démarrage : Vérifier :
```bash ```bash
docker compose logs -f docker compose ps # attendre (healthy) sur postgres, api et web
docker compose logs -f # au besoin, suivre le démarrage
``` ```
### 4. Ouvrir l'application ### 5. Ouvrir l'application
<http://localhost> <http://localhost>
Si vous avez changé `WEB_PORT`, adaptez l'adresse (par exemple `http://localhost:8080`). Si vous avez changé `WEB_PORT`, adaptez l'adresse (par exemple `http://localhost:8080`).
Depuis un autre appareil du réseau local, utilisez l'adresse IP du serveur. Depuis un autre appareil du réseau local, utilisez l'adresse IP du serveur. L'assistant de
premier démarrage s'ouvre automatiquement et crée le compte administrateur.
Deux adresses utiles : Deux adresses utiles :
- <http://localhost/api/healthz> — doit répondre `{"status":"ok"}` - <http://localhost/api/healthz> — doit répondre `{"status":"ok"}`
- <http://localhost/api/docs> — documentation interactive de l'API - <http://localhost/api/docs> — documentation interactive de l'API
> `docker-compose.yml` porte `name: lifetrack` : toutes les commandes ci-dessus sont donc
> déjà rattachées au projet `lifetrack`, sans avoir à passer `-p`.
### Arrêter, mettre à jour, repartir de zéro ### Arrêter, mettre à jour, repartir de zéro
```bash ```bash
docker compose down # arrêt, les données sont conservées docker compose down # arrêt, les données sont conservées
docker compose up -d --build # reconstruction après mise à jour du code docker compose build api # après mise à jour du code
docker compose build web
docker compose up -d # …puis redémarrage
docker compose down -v # ⚠️ supprime AUSSI le volume : toutes vos données docker compose down -v # ⚠️ supprime AUSSI le volume : toutes vos données
``` ```
@@ -317,7 +350,7 @@ L'environnement virtuel Python est déjà présent dans `apps/api/.venv`.
```bash ```bash
cd apps/api cd apps/api
# Suite de tests — 292 tests, exécutés sur SQLite en mémoire # Suite de tests — 348 tests, exécutés sur SQLite en mémoire
.venv/Scripts/python.exe -m pytest .venv/Scripts/python.exe -m pytest
# Lint et formatage # Lint et formatage
@@ -341,7 +374,9 @@ python -m venv .venv
> Les tests tournent sur **SQLite en mémoire** alors que la production utilise > Les tests tournent sur **SQLite en mémoire** alors que la production utilise
> **PostgreSQL 16** : tous les types de colonnes doivent rester portables (voir > **PostgreSQL 16** : tous les types de colonnes doivent rester portables (voir
> [CONVENTIONS.md](CONVENTIONS.md) §C8). > [CONVENTIONS.md](CONVENTIONS.md) §C8). `app/tests/test_postgres_ddl.py` vérifie hors
> ligne le DDL réellement émis pour PostgreSQL (types natifs, index partiels, cascades
> d'import) ; ce DDL a par ailleurs été confronté à un vrai serveur PostgreSQL 16.
### Frontend (React + Vite) ### Frontend (React + Vite)
@@ -388,7 +423,7 @@ LifeTrack/
│ │ │ ├─ core/ # config, base, sécurité, pagination, erreurs, │ │ │ ├─ core/ # config, base, sécurité, pagination, erreurs,
│ │ │ │ # framework d'import et d'ingestion, chargeur de modules │ │ │ │ # framework d'import et d'ingestion, chargeur de modules
│ │ │ ├─ modules/ # auth, health, vape, finance, imports │ │ │ ├─ modules/ # auth, health, vape, finance, imports
│ │ │ ├─ tests/ # 292 tests pytest │ │ │ ├─ tests/ # 348 tests pytest
│ │ │ └─ main.py # création de l'app — ne jamais modifier pour un module │ │ │ └─ main.py # création de l'app — ne jamais modifier pour un module
│ │ ├─ requirements.txt │ │ ├─ requirements.txt
│ │ └─ openapi.json # schéma OpenAPI exporté │ │ └─ openapi.json # schéma OpenAPI exporté
@@ -478,7 +513,7 @@ utilisateur.
| Base de données | PostgreSQL 16 (pilote psycopg 3) — SQLite en mémoire pour les tests | | Base de données | PostgreSQL 16 (pilote psycopg 3) — SQLite en mémoire pour les tests |
| Frontend | React 18 · TypeScript strict · Vite 6 · TailwindCSS · Apache ECharts · TanStack Query · React Router | | Frontend | React 18 · TypeScript strict · Vite 6 · TailwindCSS · Apache ECharts · TanStack Query · React Router |
| Déploiement | Docker Compose : `postgres` + `api` + `web` (nginx 1.27) | | Déploiement | Docker Compose : `postgres` + `api` + `web` (nginx 1.27) |
| Qualité | pytest (292 tests) · ruff · `tsc --noEmit` | | Qualité | pytest (348 tests) · ruff · `tsc --noEmit` |
Aucune ressource externe n'est chargée à l'exécution : polices, icônes et bibliothèques sont Aucune ressource externe n'est chargée à l'exécution : polices, icônes et bibliothèques sont
embarquées dans le build. embarquées dans le build.
+35 -4
View File
@@ -1,3 +1,4 @@
import http
from typing import Any from typing import Any
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -54,6 +55,38 @@ _HTTP_CODES = {
} }
# Starlette fills `HTTPException.detail` with the ENGLISH reason phrase of the
# status code ("Not Found", "Method Not Allowed"…). C1 forbids shipping those to
# the user, so framework-raised errors get a French message instead.
_FRENCH_MESSAGES = {
400: "Requête invalide.",
401: "Authentification requise.",
403: "Accès refusé.",
404: "Ressource introuvable.",
405: "Méthode non autorisée pour cette ressource.",
409: "Conflit avec l'état actuel de la ressource.",
413: "Le contenu envoyé est trop volumineux.",
422: "Les données envoyées sont invalides.",
429: "Trop de requêtes. Réessayez dans un instant.",
500: "Une erreur interne est survenue.",
}
_FALLBACK_MESSAGE = "Une erreur est survenue."
def _english_default(status_code: int) -> str | None:
try:
return http.HTTPStatus(status_code).phrase
except ValueError:
return None
def _french_message(exc: StarletteHTTPException) -> str:
detail = exc.detail if isinstance(exc.detail, str) else None
if detail and detail != _english_default(exc.status_code):
return detail # explicit message set by the application: keep it
return _FRENCH_MESSAGES.get(exc.status_code, _FALLBACK_MESSAGE)
def _payload(code: str, message: str, details: Any = None) -> dict[str, Any]: def _payload(code: str, message: str, details: Any = None) -> dict[str, Any]:
return {"error": {"code": code, "message": message, "details": details or {}}} return {"error": {"code": code, "message": message, "details": details or {}}}
@@ -86,9 +119,7 @@ def register_error_handlers(app: FastAPI) -> None:
# Normalize framework-raised HTTP errors (404 route, 405…) to the # Normalize framework-raised HTTP errors (404 route, 405…) to the
# single error shape of §8.3. # single error shape of §8.3.
code = _HTTP_CODES.get(exc.status_code, "error") code = _HTTP_CODES.get(exc.status_code, "error")
message = (
exc.detail if isinstance(exc.detail, str) else "Une erreur est survenue."
)
return JSONResponse( return JSONResponse(
status_code=exc.status_code, content=_payload(code, message) status_code=exc.status_code,
content=_payload(code, _french_message(exc)),
) )
@@ -34,6 +34,7 @@ WORKOUT_OVERLAP_THRESHOLD = 0.8 # cross-source workout dedup (§3.6)
ADAPTIVE_TDEE_MIN_DAYS = 21 # minimal tracked window for §5.7 ADAPTIVE_TDEE_MIN_DAYS = 21 # minimal tracked window for §5.7
TDEE_SMOOTHING_DAYS = 7 # moving average window for the budget TDEE_SMOOTHING_DAYS = 7 # moving average window for the budget
MEASURED_TOTAL_MIN_RATIO = 0.8 # total_kcal plausibility guard vs BMR MEASURED_TOTAL_MIN_RATIO = 0.8 # total_kcal plausibility guard vs BMR
PROTEIN_G_PER_KG_DEFAULT = 1.6 # default protein target (ux-pages.md §15.3)
# Field-by-field merge priority for activity_daily (§3.5). Unknown sources # Field-by-field merge priority for activity_daily (§3.5). Unknown sources
# rank after every listed one. # rank after every listed one.
@@ -216,6 +217,15 @@ def trend_at_day(trend: list[tuple[date, float]], day: date) -> float | None:
return value return value
def protein_goal_g(
weight_kg: float | None, g_per_kg: float = PROTEIN_G_PER_KG_DEFAULT
) -> float | None:
"""Daily protein target in grams (ux-pages.md §15.3: g/kg of body weight)."""
if weight_kg is None or weight_kg <= 0:
return None
return round(weight_kg * g_per_kg, 1)
def regression_slope(points: list[tuple[date, float]]) -> float | None: def regression_slope(points: list[tuple[date, float]]) -> float | None:
"""OLS slope in kg/day over (day, value) points; None if < 3 points (§5.5).""" """OLS slope in kg/day over (day, value) points; None if < 3 points (§5.5)."""
if len(points) < 3: if len(points) < 3:
+6 -1
View File
@@ -26,6 +26,7 @@ OFF_PRODUCT_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json
OFF_USER_AGENT = "LifeTrack/1.0 (meejayproduction@gmail.com)" OFF_USER_AGENT = "LifeTrack/1.0 (meejayproduction@gmail.com)"
OFF_TIMEOUT_S = 5.0 OFF_TIMEOUT_S = 5.0
OFF_SOURCE = "off" OFF_SOURCE = "off"
OFF_MAX_PAGE_SIZE = 100
OFF_FIELDS = ( OFF_FIELDS = (
"code,product_name,product_name_fr,brands,quantity,serving_size," "code,product_name,product_name_fr,brands,quantity,serving_size,"
@@ -182,8 +183,12 @@ def search_foods(db: Session, query: str, limit: int = 20) -> FoodSearchResponse
return FoodSearchResponse(query=query, items=items, origin="cache") return FoodSearchResponse(query=query, items=items, origin="cache")
known = {(row.source, row.source_id) for row in local} known = {(row.source, row.source_id) for row in local}
# The OFF results overlap the local cache: products already cached are skipped
# below, so asking only for the missing count under-delivers as soon as the
# cache is partially warm. Ask for `limit` new products plus the overlap.
page_size = min(limit + len(known), OFF_MAX_PAGE_SIZE)
try: try:
hits = fetch_off_search(query, limit - len(items)) hits = fetch_off_search(query, page_size)
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
if items: if items:
return FoodSearchResponse(query=query, items=items, origin="cache") return FoodSearchResponse(query=query, items=items, origin="cache")
+1
View File
@@ -463,6 +463,7 @@ class NutritionDayDetail(BaseModel):
totals: NutritionDayRead totals: NutritionDayRead
water_ml: int = 0 water_ml: int = 0
water_goal_ml: int | None = None water_goal_ml: int | None = None
protein_goal_g: float | None = None
meals: list[MealTotals] = Field(default_factory=list) meals: list[MealTotals] = Field(default_factory=list)
+11
View File
@@ -203,6 +203,17 @@ def weights_query(
return _apply_sort(stmt, sort, WEIGHT_SORTS, "-measured_at") return _apply_sort(stmt, sort, WEIGHT_SORTS, "-measured_at")
def latest_weight(
db: Session, user_id: int, tz: ZoneInfo, until: dt.date | None = None
) -> WeightEntry | None:
"""Most recent weigh-in (<= end of the local day `until`), or None."""
stmt = select(WeightEntry).where(WeightEntry.user_id == user_id)
if until is not None:
_, stop = utc_window(until, until, tz)
stmt = stmt.where(WeightEntry.measured_at < stop)
return db.scalar(stmt.order_by(WeightEntry.measured_at.desc()).limit(1))
def create_weight(db: Session, user_id: int, payload: WeightEntryCreate) -> WeightEntry: def create_weight(db: Session, user_id: int, payload: WeightEntryCreate) -> WeightEntry:
existing = db.scalar( existing = db.scalar(
select(WeightEntry).where( select(WeightEntry).where(
+23 -8
View File
@@ -7,6 +7,7 @@ identifiers (the UI maps them to French labels).
""" """
import datetime as dt import datetime as dt
from collections import Counter
from dataclasses import dataclass from dataclasses import dataclass
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -181,6 +182,7 @@ def weight_stats(
raw = [(d, w) for d, w in raw_all if start <= d <= end] raw = [(d, w) for d, w in raw_all if start <= d <= end]
trend = [(d, w) for d, w in trend_all if start <= d <= end] trend = [(d, w) for d, w in trend_all if start <= d <= end]
goal = service.active_goal(db, user_id) goal = service.active_goal(db, user_id)
plan_end = plan_end_date(goal) if goal is not None else None
series = [ series = [
Series( Series(
@@ -208,7 +210,6 @@ def weight_stats(
projection = calc.project_target_date( projection = calc.project_target_date(
trend_now, float(goal.target_weight_kg), slope_30 or slope_14, end trend_now, float(goal.target_weight_kg), slope_30 or slope_14, end
) )
plan_end = plan_end_date(goal)
if plan_end is not None: if plan_end is not None:
series.append( series.append(
Series( Series(
@@ -238,9 +239,15 @@ def weight_stats(
if trend_now is not None and profile is not None if trend_now is not None and profile is not None
else None else None
) )
# « Dernière pesée » must be the most recent weigh-in, like /health/dashboard
# — NOT raw_all[-1], which is the FIRST weigh-in of the most recent day (§5.5
# governs the series, not this KPI).
last_entry = service.latest_weight(db, user_id, tz, until=end)
meta = { meta = {
"trend_now_kg": _round(trend_now, 2), "trend_now_kg": _round(trend_now, 2),
"last_weight_kg": _round(raw_all[-1][1], 2) if raw_all else None, "last_weight_kg": _round(float(last_entry.weight_kg), 2)
if last_entry is not None
else None,
"slope_14d_kg_day": _round(slope_14, 4), "slope_14d_kg_day": _round(slope_14, 4),
"slope_14d_kg_week": _round(slope_14 * 7, 3) if slope_14 is not None else None, "slope_14d_kg_week": _round(slope_14 * 7, 3) if slope_14 is not None else None,
"slope_30d_kg_day": _round(slope_30, 4), "slope_30d_kg_day": _round(slope_30, 4),
@@ -250,9 +257,7 @@ def weight_stats(
else None, else None,
"bmi": _round(bmi, 1), "bmi": _round(bmi, 1),
"target_weight_kg": float(goal.target_weight_kg) if goal else None, "target_weight_kg": float(goal.target_weight_kg) if goal else None,
"plan_end_date": _iso(plan_end_date(goal)) "plan_end_date": _iso(plan_end) if plan_end is not None else None,
if goal and plan_end_date(goal)
else None,
"projection": { "projection": {
"status": projection.status, "status": projection.status,
"date": _iso(projection.date) if projection.date else None, "date": _iso(projection.date) if projection.date else None,
@@ -469,10 +474,20 @@ def energy_balance_stats(
calc.trend_at_day(trend_all, start), calc.trend_at_day(trend_all, start),
calc.trend_at_day(trend_all, end), calc.trend_at_day(trend_all, end),
) )
methods = [m.tdee_method for m in models if m.tdee_method is not None]
meta = { meta = {
"tdee_methods": { "tdee_methods": {
_iso(m.day): m.tdee_method for m in models if m.tdee_method is not None _iso(m.day): m.tdee_method for m in models if m.tdee_method is not None
}, },
# How the TDEE average was obtained (dominant method of the window) and
# the two figures the UI needs to spell it out ("BMR 1 750 × 1,55").
"tdee_mode": Counter(methods).most_common(1)[0][0] if methods else None,
"bmr_kcal": _round(
next((m.bmr_kcal for m in reversed(models) if m.bmr_kcal is not None), None)
),
"activity_factor": calc.ACTIVITY_FACTORS[profile.activity_level.value]
if profile is not None
else None,
"cumulative_balance_kcal": _round(running), "cumulative_balance_kcal": _round(running),
"cumulative_kg_equivalent": _round(running / calc.KCAL_PER_KG_FAT, 2), "cumulative_kg_equivalent": _round(running / calc.KCAL_PER_KG_FAT, 2),
# Daily deficit aimed at by the active goal on the last day of the range # Daily deficit aimed at by the active goal on the last day of the range
@@ -661,11 +676,13 @@ def nutrition_day_detail(
water = sum( water = sum(
row.volume_ml for row in service.water_between(db, user_id, day, day, tz) row.volume_ml for row in service.water_between(db, user_id, day, day, tz)
) )
trend = calc.weight_trend(service.daily_weights(db, user_id, tz, until=day))
return NutritionDayDetail( return NutritionDayDetail(
date=day, date=day,
totals=totals, totals=totals,
water_ml=water, water_ml=water,
water_goal_ml=profile.water_goal_ml if profile else None, water_goal_ml=profile.water_goal_ml if profile else None,
protein_goal_g=calc.protein_goal_g(calc.trend_at_day(trend, day)),
meals=list(meals.values()), meals=list(meals.values()),
) )
@@ -828,9 +845,7 @@ def dashboard(db: Session, user_id: int, tz: ZoneInfo) -> DashboardResponse:
trend_all = calc.weight_trend(raw_all) trend_all = calc.weight_trend(raw_all)
trend_now = trend_all[-1][1] if trend_all else None trend_now = trend_all[-1][1] if trend_all else None
trend_7d_ago = calc.trend_at_day(trend_all, today - dt.timedelta(days=7)) trend_7d_ago = calc.trend_at_day(trend_all, today - dt.timedelta(days=7))
last_entry = db.scalar( last_entry = service.latest_weight(db, user_id, tz)
service.weights_query(user_id, None, None, tz, "-measured_at").limit(1)
)
cumulative = sum(m.balance_kcal for m in models if m.balance_kcal is not None) cumulative = sum(m.balance_kcal for m in models if m.balance_kcal is not None)
week_start = today - dt.timedelta(days=today.weekday()) week_start = today - dt.timedelta(days=today.weekday())
@@ -478,4 +478,6 @@ def _row(day: str, amount: str, label: str):
booked_date=date.fromisoformat(day), booked_date=date.fromisoformat(day),
value_date=None, value_date=None,
amount=Decimal(amount), amount=Decimal(amount),
currency="EUR",
label_raw=label,
)
@@ -145,6 +145,39 @@ def test_search_matches_the_brand_locally(
assert [item["name"] for item in body["items"]] == ["Pomme, crue"] assert [item["name"] for item in body["items"]] == ["Pomme, crue"]
def test_a_partially_warm_cache_still_returns_a_full_page(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
"""OFF results overlap the local cache, so the page must be over-fetched.
Regression: `page_size` used to be `limit - len(local)`, and the overlapping
products were then skipped as already known — a warm cache under-delivered.
"""
for index in range(3):
db.add(
FoodItem(
source="off", source_id=f"off-{index}", name=f"Pomme variété {index}"
)
)
db.commit()
def handler(request: httpx.Request) -> httpx.Response:
size = int(request.url.params["page_size"])
hits = [
{
"code": f"off-{index}",
"product_name": f"Pomme variété {index}",
"nutriments": {"energy-kcal_100g": 52},
}
for index in range(size)
]
return httpx.Response(200, json={"hits": hits, "count": size})
use_transport(handler)
body = client.get(URL, params={"q": "pomme", "limit": 5}, headers=auth_headers)
assert len(body.json()["items"]) == 5
def test_search_requires_two_characters( def test_search_requires_two_characters(
client: TestClient, auth_headers: dict[str, str] client: TestClient, auth_headers: dict[str, str]
) -> None: ) -> None:
@@ -12,7 +12,16 @@ Each test below fails on the code as it was before the matching fix:
3. `/health/goals/active` reported `deficit_target_kcal` as 3. `/health/goals/active` reported `deficit_target_kcal` as
`tdee_smoothed - budget`, i.e. the deficit left AFTER the calorie floor `tdee_smoothed - budget`, i.e. the deficit left AFTER the calorie floor
clamped the budget, instead of the deficit AIMED AT by the goal clamped the budget, instead of the deficit AIMED AT by the goal
(§5.3 `BudgetResult.deficit_target = rate x 7700 / 7`). (§5.3 `BudgetResult.deficit_target = rate x 7700 / 7`);
4. `/health/energy-balance` never told the UI HOW the TDEE was obtained
(`tdee_mode`, `bmr_kcal`, `activity_factor`), so the « BMR x facteur »
sub-label of the TDEE KPI could never render;
5. `/health/nutrition/days/{day}` never sent a protein target, so the
« Protéines aujourd'hui » gauge showed `96 / — g` for ever
(ux-pages.md §15.3: default 1,6 g/kg of body weight);
6. `/health/weights/stats` reported `last_weight_kg` as the FIRST weigh-in of
the most recent day while `/health/dashboard` reported the LAST one — the
same French label « dernière pesée » showing two different numbers.
""" """
import datetime as dt import datetime as dt
@@ -273,3 +282,112 @@ def test_energy_balance_meta_exposes_the_target_deficit(
headers=auth_headers, headers=auth_headers,
).json()["meta"] ).json()["meta"]
assert meta["deficit_target_kcal"] == 550.0 # 0.5 * 7700 / 7 assert meta["deficit_target_kcal"] == 550.0 # 0.5 * 7700 / 7
# --- 4. The TDEE KPI sub-label needs bmr / factor / mode ----------------------
def test_energy_balance_meta_explains_how_the_tdee_was_obtained(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""`tdee_mode` + `bmr_kcal` + `activity_factor` back the « BMR x facteur »
sub-label of the TDEE KPI, which could never render without them."""
_seed(client, auth_headers)
today = _today()
meta = client.get(
f"{BASE}/energy-balance",
params={
"from": (today - dt.timedelta(days=6)).isoformat(),
"to": today.isoformat(),
},
headers=auth_headers,
).json()["meta"]
assert meta["tdee_mode"] == "estimated" # no activity_daily seeded
assert meta["activity_factor"] == 1.55 # profile activity_level = moderate
assert meta["bmr_kcal"] is not None
tdee_avg = meta["bmr_kcal"] * meta["activity_factor"]
assert tdee_avg > 1500
def test_energy_balance_meta_reports_a_measured_tdee_mode(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_seed(client, auth_headers)
today = _today()
for offset in range(7):
day = today - dt.timedelta(days=offset)
assert client.post(
f"{BASE}/activity",
json={"date": day.isoformat(), "steps": 12000, "active_kcal": 600},
headers=auth_headers,
).status_code in (200, 201)
meta = client.get(
f"{BASE}/energy-balance",
params={
"from": (today - dt.timedelta(days=6)).isoformat(),
"to": today.isoformat(),
},
headers=auth_headers,
).json()["meta"]
assert meta["tdee_mode"] == "bmr_plus_active"
# --- 5. The protein gauge of the nutrition journal ----------------------------
def test_nutrition_day_detail_exposes_the_protein_goal(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""« Protéines aujourd'hui : 96 / 130 g » needs a target (ux-pages §15.3,
1,6 g/kg) — the journal endpoint never sent one, so the gauge stayed at —."""
_seed(client, auth_headers)
today = _today()
body = client.get(
f"{BASE}/nutrition/days/{today.isoformat()}", headers=auth_headers
).json()
trend = client.get(f"{BASE}/dashboard", headers=auth_headers).json()[
"trend_weight_kg"
]
assert body["protein_goal_g"] == round(trend * 1.6, 1)
def test_protein_goal_is_absent_without_any_weigh_in(
client: TestClient, auth_headers: dict[str, str]
) -> None:
today = _today()
body = client.get(
f"{BASE}/nutrition/days/{today.isoformat()}", headers=auth_headers
).json()
assert body["protein_goal_g"] is None
# --- 6. « Dernière pesée » must mean the same thing everywhere ----------------
def test_last_weight_is_the_most_recent_weigh_in_of_the_day(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""/weights/stats reported the FIRST weigh-in of the last day (the series
rule of §5.5) while /dashboard reported the LAST one — same French label,
two different numbers."""
assert (
client.put(f"{BASE}/profile", json=PROFILE, headers=auth_headers).status_code
== 200
)
yesterday = _today() - dt.timedelta(days=1)
for hour, weight in ((7, 92.14), (18, 93.6)):
assert (
client.post(
f"{BASE}/weights",
json={"measured_at": _at(yesterday, hour), "weight_kg": weight},
headers=auth_headers,
).status_code
== 201
)
stats = client.get(f"{BASE}/weights/stats", headers=auth_headers).json()
dashboard = client.get(f"{BASE}/dashboard", headers=auth_headers).json()
assert stats["meta"]["last_weight_kg"] == 93.6
assert dashboard["weight_kg"] == 93.6
# the trend series still uses the first weigh-in of the day (§5.5)
raw = next(s for s in stats["series"] if s["name"] == "weight_raw")
assert raw["points"][-1][1] == 92.14
+18
View File
@@ -225,6 +225,24 @@ def test_every_module_uses_the_single_error_envelope(client: TestClient) -> None
assert error["message"].endswith("."), path assert error["message"].endswith("."), path
def test_framework_errors_are_answered_in_french(client: TestClient) -> None:
"""Starlette fills `detail` with an English reason phrase ("Not Found",
"Method Not Allowed"); C1 forbids shipping it to the user."""
unknown = client.get("/api/inconnu")
assert unknown.status_code == 404
assert unknown.json()["error"] == {
"code": "not_found",
"message": "Ressource introuvable.",
"details": {},
}
wrong_method = client.delete("/api/healthz")
assert wrong_method.status_code == 405
error = wrong_method.json()["error"]
assert error["code"] == "method_not_allowed"
assert error["message"] == "Méthode non autorisée pour cette ressource."
# --- Home page: one dashboard per module (§7.1 of ux-pages) ------------------- # --- Home page: one dashboard per module (§7.1 of ux-pages) -------------------
+8 -10
View File
@@ -170,7 +170,9 @@ def test_enum_columns_are_varchar_with_a_check_constraint() -> None:
def test_uuid_columns_use_the_native_postgresql_uuid_type() -> None: def test_uuid_columns_use_the_native_postgresql_uuid_type() -> None:
uuid_columns = [ uuid_columns = [
(table, column) for table, column in _columns() if isinstance(column.type, sa.Uuid) (table, column)
for table, column in _columns()
if isinstance(column.type, sa.Uuid)
] ]
assert len(uuid_columns) >= 15 assert len(uuid_columns) >= 15
for table, column in uuid_columns: for table, column in uuid_columns:
@@ -201,15 +203,13 @@ def test_every_datetime_column_is_timestamptz() -> None:
def test_numeric_columns_declare_precision_and_scale() -> None: def test_numeric_columns_declare_precision_and_scale() -> None:
"""A bare NUMERIC on PostgreSQL stores any precision: money would drift.""" """A bare NUMERIC on PostgreSQL stores any precision: money would drift."""
for table, column in _columns(): for table, column in _columns():
if not isinstance(column.type, sa.Numeric) or isinstance( if not isinstance(column.type, sa.Numeric) or isinstance(column.type, sa.Float):
column.type, sa.Float
):
continue continue
assert column.type.precision is not None, f"{table.name}.{column.name}" assert column.type.precision is not None, f"{table.name}.{column.name}"
assert column.type.scale is not None, f"{table.name}.{column.name}" assert column.type.scale is not None, f"{table.name}.{column.name}"
assert re.fullmatch( assert re.fullmatch(r"NUMERIC\(\d+, \d+\)", column.type.compile(dialect=PG)), (
r"NUMERIC\(\d+, \d+\)", column.type.compile(dialect=PG) f"{table.name}.{column.name}"
), f"{table.name}.{column.name}" )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -301,9 +301,7 @@ def test_index_and_unique_constraint_names_are_unique_schema_wide() -> None:
for constraint in table.constraints for constraint in table.constraints
if constraint.name is not None if constraint.name is not None
and not str(constraint.name).startswith("_unnamed_") and not str(constraint.name).startswith("_unnamed_")
and isinstance( and isinstance(constraint, sa.UniqueConstraint | sa.PrimaryKeyConstraint)
constraint, sa.UniqueConstraint | sa.PrimaryKeyConstraint
)
] ]
for name, owner in owners: for name, owner in owners:
assert name is not None assert name is not None
+14 -3
View File
@@ -4050,7 +4050,7 @@
"schema": { "schema": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "$ref": "#/components/schemas/SportType"
}, },
{ {
"type": "null" "type": "null"
@@ -4401,7 +4401,7 @@
"schema": { "schema": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "$ref": "#/components/schemas/GoalStatus"
}, },
{ {
"type": "null" "type": "null"
@@ -5824,7 +5824,7 @@
"schema": { "schema": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "$ref": "#/components/schemas/MealType"
}, },
{ {
"type": "null" "type": "null"
@@ -13750,6 +13750,17 @@
], ],
"title": "Water Goal Ml" "title": "Water Goal Ml"
}, },
"protein_goal_g": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Protein Goal G"
},
"meals": { "meals": {
"items": { "items": {
"$ref": "#/components/schemas/MealTotals" "$ref": "#/components/schemas/MealTotals"
+16 -3
View File
@@ -330,6 +330,10 @@ export interface WorkoutStatsMeta {
/* Health — energy balance */ /* Health — energy balance */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** TDEE derivation, 3-tier rule of datamodel-health-vape.md §5.2. */
export const TDEE_METHODS = ["measured_total", "bmr_plus_active", "estimated"] as const;
export type TdeeMethod = (typeof TDEE_METHODS)[number];
export interface EnergyBalanceMeta { export interface EnergyBalanceMeta {
intake_avg?: number | null; intake_avg?: number | null;
tdee_avg?: number | null; tdee_avg?: number | null;
@@ -338,9 +342,10 @@ export interface EnergyBalanceMeta {
balance_cumulative?: number | null; balance_cumulative?: number | null;
budget_kcal?: number | null; budget_kcal?: number | null;
deficit_target_kcal?: number | null; deficit_target_kcal?: number | null;
bmr?: number | null; bmr_kcal?: number | null;
activity_factor?: number | null; activity_factor?: number | null;
tdee_mode?: "factor" | "measured" | null; /** Dominant TDEE method over the window — same vocabulary as the API. */
tdee_mode?: TdeeMethod | null;
tdee_methods?: Record<string, string> | null; tdee_methods?: Record<string, string> | null;
expected_change_kg?: number | null; expected_change_kg?: number | null;
actual_change_kg?: number | null; actual_change_kg?: number | null;
@@ -472,7 +477,6 @@ export interface NutritionDaysMeta {
protein_pct?: number | null; protein_pct?: number | null;
carbs_pct?: number | null; carbs_pct?: number | null;
fat_pct?: number | null; fat_pct?: number | null;
protein_goal_g?: number | null;
} }
export interface NutritionDaysResponse { export interface NutritionDaysResponse {
@@ -494,6 +498,8 @@ export interface NutritionDayDetail {
carbs_g: number; carbs_g: number;
fat_g: number; fat_g: number;
budget_kcal?: number | null; budget_kcal?: number | null;
/** Daily protein target in grams (server-side, 1,6 g/kg of trend weight). */
protein_goal_g?: number | null;
} }
export interface FoodFavoriteRead { export interface FoodFavoriteRead {
@@ -868,6 +874,11 @@ export function useEnergyBalance(
tdee_avg: seriesAverage(stats, "tdee_kcal"), tdee_avg: seriesAverage(stats, "tdee_kcal"),
budget_kcal: seriesLast(stats, "budget_kcal"), budget_kcal: seriesLast(stats, "budget_kcal"),
deficit_target_kcal: numberOrNull(meta.deficit_target_kcal), deficit_target_kcal: numberOrNull(meta.deficit_target_kcal),
bmr_kcal: numberOrNull(meta.bmr_kcal),
activity_factor: numberOrNull(meta.activity_factor),
tdee_mode: TDEE_METHODS.includes(meta.tdee_mode as TdeeMethod)
? (meta.tdee_mode as TdeeMethod)
: null,
balance_cumulative: numberOrNull(meta.cumulative_balance_kcal), balance_cumulative: numberOrNull(meta.cumulative_balance_kcal),
expected_change_kg: numberOrNull(meta.expected_change_kg), expected_change_kg: numberOrNull(meta.expected_change_kg),
actual_change_kg: numberOrNull(meta.actual_change_kg), actual_change_kg: numberOrNull(meta.actual_change_kg),
@@ -1056,6 +1067,7 @@ interface NutritionDayDetailEnvelope {
} | null; } | null;
water_ml?: number; water_ml?: number;
water_goal_ml?: number | null; water_goal_ml?: number | null;
protein_goal_g?: number | null;
meals?: { meal: MealType; kcal?: number; entries?: FoodEntryRead[] }[]; meals?: { meal: MealType; kcal?: number; entries?: FoodEntryRead[] }[];
} }
@@ -1093,6 +1105,7 @@ function normalizeDayDetail(date: string, res: NutritionDayDetailEnvelope): Nutr
carbs_g: totals.carbs_g ?? sum((e) => e.carbs_g), carbs_g: totals.carbs_g ?? sum((e) => e.carbs_g),
fat_g: totals.fat_g ?? sum((e) => e.fat_g), fat_g: totals.fat_g ?? sum((e) => e.fat_g),
budget_kcal: totals.budget_kcal ?? null, budget_kcal: totals.budget_kcal ?? null,
protein_goal_g: res.protein_goal_g ?? null,
}; };
} }
@@ -94,6 +94,20 @@ export default function EnergyBalancePage() {
? -Math.abs(Number(meta.deficit_target_kcal)) ? -Math.abs(Number(meta.deficit_target_kcal))
: null; : null;
/** Sous-titre du KPI TDEE : comment la dépense a été obtenue (§5.2). */
const tdeeModeLabel = useMemo(() => {
const bmr = meta?.bmr_kcal;
const labels = S.energy.tdeeMode;
if (meta?.tdee_mode === "measured_total") return labels.measured_total;
if (bmr === null || bmr === undefined) return undefined;
const bmrText = formatNumber(Math.round(Number(bmr)));
if (meta?.tdee_mode === "bmr_plus_active") return labels.bmr_plus_active(bmrText);
if (meta?.tdee_mode === "estimated" && meta.activity_factor) {
return labels.estimated(bmrText, formatNumber(Number(meta.activity_factor), 2));
}
return undefined;
}, [meta]);
/* --- KPI --------------------------------------------------------- */ /* --- KPI --------------------------------------------------------- */
const today = todayIso(); const today = todayIso();
@@ -470,16 +484,7 @@ export default function EnergyBalancePage() {
? S.common.none ? S.common.none
: `${formatNumber(Math.round(Number(meta.tdee_avg)))} kcal/j` : `${formatNumber(Math.round(Number(meta.tdee_avg)))} kcal/j`
} }
sub={ sub={tdeeModeLabel}
meta?.tdee_mode === "measured"
? "BMR + actives mesurées"
: meta?.bmr
? `BMR ${formatNumber(Math.round(Number(meta.bmr)))} × ${formatNumber(
Number(meta.activity_factor ?? 1),
2,
)}`
: undefined
}
/> />
<StatCard <StatCard
label={S.energy.kpi.budget} label={S.energy.kpi.budget}
@@ -560,6 +565,7 @@ export default function EnergyBalancePage() {
{methodOpen ? ( {methodOpen ? (
<div className="mt-3 space-y-3 text-sm text-ink-secondary"> <div className="mt-3 space-y-3 text-sm text-ink-secondary">
<p>{S.energy.method.text}</p> <p>{S.energy.method.text}</p>
<p>{S.energy.method.workoutsNote}</p>
{meta?.tdee_adaptive_kcal ? ( {meta?.tdee_adaptive_kcal ? (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<p className="text-ink"> <p className="text-ink">
@@ -131,7 +131,7 @@ export default function NutritionPage() {
}, [days, meta]); }, [days, meta]);
const proteinToday = journal.data?.protein_g ?? todayRow?.protein_g ?? null; const proteinToday = journal.data?.protein_g ?? todayRow?.protein_g ?? null;
const proteinGoal = meta?.protein_goal_g ?? null; const proteinGoal = journal.data?.protein_goal_g ?? null;
const splitToday = useMemo(() => { const splitToday = useMemo(() => {
const p = Number(journal.data?.protein_g ?? 0) * KCAL_PER_G.protein; const p = Number(journal.data?.protein_g ?? 0) * KCAL_PER_G.protein;
+8
View File
@@ -313,9 +313,17 @@ export const S = {
theoreticalWeight: "Poids théorique", theoreticalWeight: "Poids théorique",
targetDeficit: "Cible", targetDeficit: "Cible",
}, },
/** Sous-titre du KPI « TDEE estimé » — comment la dépense a été obtenue (§5.2). */
tdeeMode: {
measured_total: "Dépense totale mesurée",
bmr_plus_active: (bmr: string) => `BMR ${bmr} + calories actives mesurées`,
estimated: (bmr: string, factor: string) => `BMR ${bmr} × ${factor}`,
},
method: { method: {
title: "Méthode & calibration", title: "Méthode & calibration",
text: "Le métabolisme de base (BMR) est calculé avec la formule Mifflin-St Jeor à partir du poids de tendance du jour. La dépense totale (TDEE) applique ensuite votre facteur d'activité, ou ajoute vos calories actives mesurées. Les conversions énergie ↔ masse utilisent 7 700 kcal par kilogramme.", text: "Le métabolisme de base (BMR) est calculé avec la formule Mifflin-St Jeor à partir du poids de tendance du jour. La dépense totale (TDEE) applique ensuite votre facteur d'activité, ou ajoute vos calories actives mesurées. Les conversions énergie ↔ masse utilisent 7 700 kcal par kilogramme.",
workoutsNote:
"Les séances de sport ne sont pas ajoutées à la dépense du jour : seule l'activité quotidienne (pas, calories actives, dépense totale) alimente le TDEE. Une séance saisie sans donnée d'activité correspondante reste donc invisible dans cette balance.",
calibration: "Calibration", calibration: "Calibration",
insufficient: "Pas encore assez de jours suivis pour calibrer le modèle (21 jours minimum).", insufficient: "Pas encore assez de jours suivis pour calibrer le modèle (21 jours minimum).",
}, },
@@ -128,6 +128,9 @@ export function StepVerify({
formatNumber(preview.rows_total), formatNumber(preview.rows_total),
formatNumber(preview.would_skip_duplicates), formatNumber(preview.would_skip_duplicates),
formatNumber(preview.rows_error), formatNumber(preview.rows_error),
preview.rows_skipped_filtered
? formatNumber(preview.rows_skipped_filtered)
: undefined,
)} )}
</span> </span>
{preview.date_min && preview.date_max ? ( {preview.date_min && preview.date_max ? (
+4 -2
View File
@@ -90,8 +90,10 @@ export const strings = {
preview: { preview: {
title: "Aperçu des 20 premières lignes", title: "Aperçu des 20 premières lignes",
summary: (read: string, duplicates: string, errors: string) => summary: (read: string, duplicates: string, errors: string, filtered?: string) =>
`${read} lignes lues · ${duplicates} doublons ignorés (déjà importés) · ${errors} lignes en erreur`, `${read} lignes lues · ${duplicates} doublons ignorés (déjà importés)` +
(filtered ? ` · ${filtered} lignes écartées par le profil` : "") +
` · ${errors} lignes en erreur`,
period: "Période couverte", period: "Période couverte",
colDate: "Date", colDate: "Date",
colLabel: "Libellé", colLabel: "Libellé",
+10
View File
@@ -8,6 +8,16 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Above client_max_body_size nginx answers on its own, with an English HTML
# page. Only files bigger than 25 MiB get here (between the API limit and
# 25 MiB the API answers first, in French): keep the §8.3 error envelope.
error_page 413 = @too_large;
location @too_large {
default_type application/json;
charset utf-8;
return 413 '{"error":{"code":"payload_too_large","message":"Fichier trop volumineux (limite : 20 Mio).","details":{}}}';
}
# Docker's embedded DNS. Without a resolver nginx resolves `api` once, at # Docker's embedded DNS. Without a resolver nginx resolves `api` once, at
# config load, and caches that IP forever: `compose restart api` (or any # config load, and caches that IP forever: `compose restart api` (or any
# recreate) hands out a new IP and every /api/ call would 502 until nginx # recreate) hands out a new IP and every /api/ call would 502 until nginx