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`)
|
### 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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) -------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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é",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user