Fix 20+ defects found by real Docker/PostgreSQL deployment

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:40:05 +02:00
co-authored by Claude Opus 5
parent d32436db4c
commit fa3db7ff22
20 changed files with 408 additions and 78 deletions
+35 -4
View File
@@ -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:
+6 -1
View File
@@ -26,6 +26,7 @@ OFF_PRODUCT_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json
OFF_USER_AGENT = "LifeTrack/1.0 (meejayproduction@gmail.com)"
OFF_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")
+1
View File
@@ -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)
+11
View File
@@ -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(
+23 -8
View File
@@ -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
+18
View File
@@ -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) -------------------
+8 -10
View File
@@ -170,7 +170,9 @@ def test_enum_columns_are_varchar_with_a_check_constraint() -> None:
def test_uuid_columns_use_the_native_postgresql_uuid_type() -> None:
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
+15 -4
View File
@@ -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"
@@ -18278,4 +18289,4 @@
}
}
}
}
}
+16 -3
View File
@@ -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;
+8
View File
@@ -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 ? (
+4 -2
View File
@@ -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é",