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:
@@ -127,8 +127,9 @@ Détails utiles :
|
||||
|
||||
### Imports et connecteurs (`/imports`)
|
||||
|
||||
- Assistant en trois étapes : **Fichier → Vérification → Import**, avec détection
|
||||
automatique du format à partir de l'en-tête du fichier.
|
||||
- Assistant en trois étapes : **Fichier → Vérification → Import**. Le format est détecté à
|
||||
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é
|
||||
dans un profil personnel.
|
||||
- **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.**
|
||||
|
||||
L'application a été développée et testée intégralement hors ligne : la suite de tests
|
||||
backend et la compilation du frontend passent, mais **la pile Docker Compose n'a jamais été
|
||||
démarrée** (le démon Docker était indisponible pendant tout le développement). Aucune
|
||||
capture n'a donc pu être prise sur une instance réelle.
|
||||
|
||||
Plutôt que de publier des images de synthèse trompeuses, cette section restera vide jusqu'à
|
||||
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`.
|
||||
La pile a bien été construite et exécutée (voir « Ce qui est vérifié » ci-dessous), mais la
|
||||
validation s'est faite **en HTTP, pas dans un navigateur** : aucune session graphique n'a
|
||||
été ouverte, donc aucune capture authentique n'a pu être prise. Plutôt que de publier des
|
||||
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
|
||||
`/finances`.
|
||||
|
||||
---
|
||||
|
||||
@@ -165,18 +164,32 @@ sont : le tableau de bord, `/sante/poids`, `/vape/economies` et `/finances`.
|
||||
|
||||
### Prérequis
|
||||
|
||||
- **Docker Desktop installé ET démarré.** Vérifiez que la baleine est bien active dans la
|
||||
barre des tâches ; `docker compose version` doit répondre sans erreur.
|
||||
- **Docker installé et démarré** ; `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.
|
||||
- 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
|
||||
> 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).
|
||||
> Attendez-vous éventuellement à un petit ajustement lors du tout premier `up` — le plus
|
||||
> 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
|
||||
> `docker compose logs api` avant toute autre chose.
|
||||
### Ce qui est vérifié
|
||||
|
||||
La séquence ci-dessous a été **exécutée telle quelle** sur un hôte Ubuntu (Docker Compose
|
||||
v5.1.1, 8 vCPU, 1,8 Go de RAM), à partir d'un volume de données vide :
|
||||
|
||||
- les deux images se construisent (`lifetrack-api` ≈ 330 Mo, `lifetrack-web` ≈ 76 Mo) ;
|
||||
- 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`
|
||||
|
||||
@@ -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
|
||||
> 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
|
||||
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`
|
||||
(nginx servant le frontend compilé et relayant `/api/` vers l'API). Le premier build prend
|
||||
plusieurs minutes.
|
||||
(nginx servant le frontend compilé et relayant `/api/` vers l'API). Depuis un volume vide,
|
||||
il faut une vingtaine de secondes pour que les trois soient `healthy`.
|
||||
|
||||
Suivre le démarrage :
|
||||
Vérifier :
|
||||
|
||||
```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>
|
||||
|
||||
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 :
|
||||
|
||||
- <http://localhost/api/healthz> — doit répondre `{"status":"ok"}`
|
||||
- <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
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
@@ -317,7 +350,7 @@ L'environnement virtuel Python est déjà présent dans `apps/api/.venv`.
|
||||
```bash
|
||||
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
|
||||
|
||||
# Lint et formatage
|
||||
@@ -341,7 +374,9 @@ python -m venv .venv
|
||||
|
||||
> Les tests tournent sur **SQLite en mémoire** alors que la production utilise
|
||||
> **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)
|
||||
|
||||
@@ -388,7 +423,7 @@ LifeTrack/
|
||||
│ │ │ ├─ core/ # config, base, sécurité, pagination, erreurs,
|
||||
│ │ │ │ # framework d'import et d'ingestion, chargeur de modules
|
||||
│ │ │ ├─ 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
|
||||
│ │ ├─ requirements.txt
|
||||
│ │ └─ 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 |
|
||||
| Frontend | React 18 · TypeScript strict · Vite 6 · TailwindCSS · Apache ECharts · TanStack Query · React Router |
|
||||
| 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
|
||||
embarquées dans le build.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import http
|
||||
from typing import Any
|
||||
|
||||
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]:
|
||||
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
|
||||
# single error shape of §8.3.
|
||||
code = _HTTP_CODES.get(exc.status_code, "error")
|
||||
message = (
|
||||
exc.detail if isinstance(exc.detail, str) else "Une erreur est survenue."
|
||||
)
|
||||
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
|
||||
TDEE_SMOOTHING_DAYS = 7 # moving average window for the budget
|
||||
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
|
||||
# rank after every listed one.
|
||||
@@ -216,6 +217,15 @@ def trend_at_day(trend: list[tuple[date, float]], day: date) -> float | None:
|
||||
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:
|
||||
"""OLS slope in kg/day over (day, value) points; None if < 3 points (§5.5)."""
|
||||
if len(points) < 3:
|
||||
|
||||
@@ -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_TIMEOUT_S = 5.0
|
||||
OFF_SOURCE = "off"
|
||||
OFF_MAX_PAGE_SIZE = 100
|
||||
|
||||
OFF_FIELDS = (
|
||||
"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")
|
||||
|
||||
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:
|
||||
hits = fetch_off_search(query, limit - len(items))
|
||||
hits = fetch_off_search(query, page_size)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
if items:
|
||||
return FoodSearchResponse(query=query, items=items, origin="cache")
|
||||
|
||||
@@ -463,6 +463,7 @@ class NutritionDayDetail(BaseModel):
|
||||
totals: NutritionDayRead
|
||||
water_ml: int = 0
|
||||
water_goal_ml: int | None = None
|
||||
protein_goal_g: float | None = None
|
||||
meals: list[MealTotals] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -203,6 +203,17 @@ def weights_query(
|
||||
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:
|
||||
existing = db.scalar(
|
||||
select(WeightEntry).where(
|
||||
|
||||
@@ -7,6 +7,7 @@ identifiers (the UI maps them to French labels).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -181,6 +182,7 @@ def weight_stats(
|
||||
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]
|
||||
goal = service.active_goal(db, user_id)
|
||||
plan_end = plan_end_date(goal) if goal is not None else None
|
||||
|
||||
series = [
|
||||
Series(
|
||||
@@ -208,7 +210,6 @@ def weight_stats(
|
||||
projection = calc.project_target_date(
|
||||
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:
|
||||
series.append(
|
||||
Series(
|
||||
@@ -238,9 +239,15 @@ def weight_stats(
|
||||
if trend_now is not None and profile is not 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 = {
|
||||
"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_week": _round(slope_14 * 7, 3) if slope_14 is not None else None,
|
||||
"slope_30d_kg_day": _round(slope_30, 4),
|
||||
@@ -250,9 +257,7 @@ def weight_stats(
|
||||
else None,
|
||||
"bmi": _round(bmi, 1),
|
||||
"target_weight_kg": float(goal.target_weight_kg) if goal else None,
|
||||
"plan_end_date": _iso(plan_end_date(goal))
|
||||
if goal and plan_end_date(goal)
|
||||
else None,
|
||||
"plan_end_date": _iso(plan_end) if plan_end is not None else None,
|
||||
"projection": {
|
||||
"status": projection.status,
|
||||
"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, end),
|
||||
)
|
||||
methods = [m.tdee_method for m in models if m.tdee_method is not None]
|
||||
meta = {
|
||||
"tdee_methods": {
|
||||
_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_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
|
||||
@@ -661,11 +676,13 @@ def nutrition_day_detail(
|
||||
water = sum(
|
||||
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(
|
||||
date=day,
|
||||
totals=totals,
|
||||
water_ml=water,
|
||||
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()),
|
||||
)
|
||||
|
||||
@@ -828,9 +845,7 @@ def dashboard(db: Session, user_id: int, tz: ZoneInfo) -> DashboardResponse:
|
||||
trend_all = calc.weight_trend(raw_all)
|
||||
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))
|
||||
last_entry = db.scalar(
|
||||
service.weights_query(user_id, None, None, tz, "-measured_at").limit(1)
|
||||
)
|
||||
last_entry = service.latest_weight(db, user_id, tz)
|
||||
|
||||
cumulative = sum(m.balance_kcal for m in models if m.balance_kcal is not None)
|
||||
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),
|
||||
value_date=None,
|
||||
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"]
|
||||
|
||||
|
||||
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(
|
||||
client: TestClient, auth_headers: dict[str, str]
|
||||
) -> 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
|
||||
`tdee_smoothed - budget`, i.e. the deficit left AFTER the calorie floor
|
||||
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
|
||||
@@ -273,3 +282,112 @@ def test_energy_balance_meta_exposes_the_target_deficit(
|
||||
headers=auth_headers,
|
||||
).json()["meta"]
|
||||
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
|
||||
|
||||
@@ -225,6 +225,24 @@ def test_every_module_uses_the_single_error_envelope(client: TestClient) -> None
|
||||
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) -------------------
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
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:
|
||||
"""A bare NUMERIC on PostgreSQL stores any precision: money would drift."""
|
||||
for table, column in _columns():
|
||||
if not isinstance(column.type, sa.Numeric) or isinstance(
|
||||
column.type, sa.Float
|
||||
):
|
||||
if not isinstance(column.type, sa.Numeric) or isinstance(column.type, sa.Float):
|
||||
continue
|
||||
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 re.fullmatch(
|
||||
r"NUMERIC\(\d+, \d+\)", column.type.compile(dialect=PG)
|
||||
), f"{table.name}.{column.name}"
|
||||
assert re.fullmatch(r"NUMERIC\(\d+, \d+\)", column.type.compile(dialect=PG)), (
|
||||
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
|
||||
if constraint.name is not None
|
||||
and not str(constraint.name).startswith("_unnamed_")
|
||||
and isinstance(
|
||||
constraint, sa.UniqueConstraint | sa.PrimaryKeyConstraint
|
||||
)
|
||||
and isinstance(constraint, sa.UniqueConstraint | sa.PrimaryKeyConstraint)
|
||||
]
|
||||
for name, owner in owners:
|
||||
assert name is not None
|
||||
|
||||
+14
-3
@@ -4050,7 +4050,7 @@
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/SportType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -4401,7 +4401,7 @@
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/GoalStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -5824,7 +5824,7 @@
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/MealType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -13750,6 +13750,17 @@
|
||||
],
|
||||
"title": "Water Goal Ml"
|
||||
},
|
||||
"protein_goal_g": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Protein Goal G"
|
||||
},
|
||||
"meals": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MealTotals"
|
||||
|
||||
@@ -330,6 +330,10 @@ export interface WorkoutStatsMeta {
|
||||
/* 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 {
|
||||
intake_avg?: number | null;
|
||||
tdee_avg?: number | null;
|
||||
@@ -338,9 +342,10 @@ export interface EnergyBalanceMeta {
|
||||
balance_cumulative?: number | null;
|
||||
budget_kcal?: number | null;
|
||||
deficit_target_kcal?: number | null;
|
||||
bmr?: number | null;
|
||||
bmr_kcal?: 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;
|
||||
expected_change_kg?: number | null;
|
||||
actual_change_kg?: number | null;
|
||||
@@ -472,7 +477,6 @@ export interface NutritionDaysMeta {
|
||||
protein_pct?: number | null;
|
||||
carbs_pct?: number | null;
|
||||
fat_pct?: number | null;
|
||||
protein_goal_g?: number | null;
|
||||
}
|
||||
|
||||
export interface NutritionDaysResponse {
|
||||
@@ -494,6 +498,8 @@ export interface NutritionDayDetail {
|
||||
carbs_g: number;
|
||||
fat_g: number;
|
||||
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 {
|
||||
@@ -868,6 +874,11 @@ export function useEnergyBalance(
|
||||
tdee_avg: seriesAverage(stats, "tdee_kcal"),
|
||||
budget_kcal: seriesLast(stats, "budget_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),
|
||||
expected_change_kg: numberOrNull(meta.expected_change_kg),
|
||||
actual_change_kg: numberOrNull(meta.actual_change_kg),
|
||||
@@ -1056,6 +1067,7 @@ interface NutritionDayDetailEnvelope {
|
||||
} | null;
|
||||
water_ml?: number;
|
||||
water_goal_ml?: number | null;
|
||||
protein_goal_g?: number | null;
|
||||
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),
|
||||
fat_g: totals.fat_g ?? sum((e) => e.fat_g),
|
||||
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))
|
||||
: 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 --------------------------------------------------------- */
|
||||
|
||||
const today = todayIso();
|
||||
@@ -470,16 +484,7 @@ export default function EnergyBalancePage() {
|
||||
? S.common.none
|
||||
: `${formatNumber(Math.round(Number(meta.tdee_avg)))} kcal/j`
|
||||
}
|
||||
sub={
|
||||
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
|
||||
}
|
||||
sub={tdeeModeLabel}
|
||||
/>
|
||||
<StatCard
|
||||
label={S.energy.kpi.budget}
|
||||
@@ -560,6 +565,7 @@ export default function EnergyBalancePage() {
|
||||
{methodOpen ? (
|
||||
<div className="mt-3 space-y-3 text-sm text-ink-secondary">
|
||||
<p>{S.energy.method.text}</p>
|
||||
<p>{S.energy.method.workoutsNote}</p>
|
||||
{meta?.tdee_adaptive_kcal ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-ink">
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function NutritionPage() {
|
||||
}, [days, meta]);
|
||||
|
||||
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 p = Number(journal.data?.protein_g ?? 0) * KCAL_PER_G.protein;
|
||||
|
||||
@@ -313,9 +313,17 @@ export const S = {
|
||||
theoreticalWeight: "Poids théorique",
|
||||
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: {
|
||||
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.",
|
||||
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",
|
||||
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.would_skip_duplicates),
|
||||
formatNumber(preview.rows_error),
|
||||
preview.rows_skipped_filtered
|
||||
? formatNumber(preview.rows_skipped_filtered)
|
||||
: undefined,
|
||||
)}
|
||||
</span>
|
||||
{preview.date_min && preview.date_max ? (
|
||||
|
||||
@@ -90,8 +90,10 @@ export const strings = {
|
||||
|
||||
preview: {
|
||||
title: "Aperçu des 20 premières lignes",
|
||||
summary: (read: string, duplicates: string, errors: string) =>
|
||||
`${read} lignes lues · ${duplicates} doublons ignorés (déjà importés) · ${errors} lignes en erreur`,
|
||||
summary: (read: string, duplicates: string, errors: string, filtered?: string) =>
|
||||
`${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",
|
||||
colDate: "Date",
|
||||
colLabel: "Libellé",
|
||||
|
||||
@@ -8,6 +8,16 @@ server {
|
||||
root /usr/share/nginx/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
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user