From 49e9c9c214d5d299cef0d49ce47ca142448f7995 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 6 Apr 2026 06:08:37 +0200 Subject: [PATCH] feat: Integrate caliper data enrichment and weight loading in API responses - Enhanced the caliper listing and export functionalities to include enriched data from weight logs. - Updated the upsert and update operations to utilize new composition functions for body composition calculations. - Refactored the CaliperScreen component to streamline payload construction by removing unnecessary parameters. --- backend/caliper_composition.py | 111 ++++++++++++++++++ .../035_caliper_lean_mass_backfill.sql | 24 ++++ backend/routers/caliper.py | 9 +- backend/routers/exportdata.py | 24 +++- backend/routers/nutrition.py | 9 +- frontend/src/pages/CaliperScreen.jsx | 20 ++-- 6 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 backend/caliper_composition.py create mode 100644 backend/migrations/035_caliper_lean_mass_backfill.sql diff --git a/backend/caliper_composition.py b/backend/caliper_composition.py new file mode 100644 index 0000000..ff3eda6 --- /dev/null +++ b/backend/caliper_composition.py @@ -0,0 +1,111 @@ +"""Derive lean_mass and fat_mass from weight_log + body_fat_pct for caliper entries.""" +from datetime import date, datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple + +from db import get_cursor, r2d + + +def as_date(d: Any) -> Optional[date]: + if d is None: + return None + if isinstance(d, datetime): + return d.date() + if isinstance(d, date): + return d + if isinstance(d, str) and len(d) >= 10: + return datetime.strptime(d[:10], "%Y-%m-%d").date() + return None + + +def _floaty(x: Any) -> float: + if isinstance(x, Decimal): + return float(x) + return float(x) + + +def load_weight_rows(conn: Any, profile_id: str) -> List[Dict[str, Any]]: + cur = get_cursor(conn) + cur.execute( + "SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", + (profile_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +def nearest_weight_kg(weight_rows: List[Dict[str, Any]], caliper_date: Any) -> Optional[float]: + td = as_date(caliper_date) + if td is None or not weight_rows: + return None + best: Optional[float] = None + best_delta: Optional[int] = None + for row in weight_rows: + wd = as_date(row.get("date")) + if wd is None: + continue + wraw = row.get("weight") + if wraw is None: + continue + delta = abs((wd - td).days) + if best_delta is None or delta < best_delta: + best_delta = delta + best = _floaty(wraw) + return best + + +def nearest_weight_kg_from_map(wlog: Dict[Any, Any], target_date: Any) -> Optional[float]: + if not wlog: + return None + td = as_date(target_date) + if td is None: + return None + best_w: Optional[float] = None + best_delta: Optional[int] = None + for wd, wval in wlog.items(): + if wval is None: + continue + wdate = as_date(wd) + if wdate is None: + continue + delta = abs((wdate - td).days) + if best_delta is None or delta < best_delta: + best_delta = delta + best_w = _floaty(wval) + return best_w + + +def compute_lean_fat_kg(weight_kg: float, body_fat_pct: float) -> Tuple[float, float]: + lean = round(weight_kg - (weight_kg * body_fat_pct / 100.0), 2) + fat = round(weight_kg * body_fat_pct / 100.0, 2) + return lean, fat + + +def fill_caliper_body_comp(d: Dict[str, Any], conn: Any, profile_id: str) -> None: + """Mutate caliper API payload (model_dump) before INSERT/UPDATE.""" + bf = d.get("body_fat_pct") + if bf is None: + return + if d.get("lean_mass") is not None and d.get("fat_mass") is not None: + return + rows = load_weight_rows(conn, profile_id) + w = nearest_weight_kg(rows, d.get("date")) + if w is None: + return + lean, fat = compute_lean_fat_kg(w, _floaty(bf)) + d["lean_mass"] = lean + d["fat_mass"] = fat + + +def enrich_caliper_row_for_response(row: Dict[str, Any], weight_rows: List[Dict[str, Any]]) -> None: + """Fill missing lean_mass / fat_mass for API responses (read path).""" + bf = row.get("body_fat_pct") + if bf is None: + return + if row.get("lean_mass") is not None and row.get("fat_mass") is not None: + return + w = nearest_weight_kg(weight_rows, row.get("date")) + if w is None: + return + lean, fat = compute_lean_fat_kg(w, _floaty(bf)) + row["lean_mass"] = lean + row["fat_mass"] = fat diff --git a/backend/migrations/035_caliper_lean_mass_backfill.sql b/backend/migrations/035_caliper_lean_mass_backfill.sql new file mode 100644 index 0000000..35f327d --- /dev/null +++ b/backend/migrations/035_caliper_lean_mass_backfill.sql @@ -0,0 +1,24 @@ +-- Migration 035: Backfill caliper lean_mass / fat_mass from nearest weight_log row +-- Date: 2026-04-06 + +UPDATE caliper_log c +SET + lean_mass = sub.lean_mass, + fat_mass = sub.fat_mass +FROM ( + SELECT + c2.id, + round((wl.weight::numeric - (wl.weight::numeric * c2.body_fat_pct::numeric / 100.0))::numeric, 2) AS lean_mass, + round((wl.weight::numeric * c2.body_fat_pct::numeric / 100.0)::numeric, 2) AS fat_mass + FROM caliper_log c2 + CROSS JOIN LATERAL ( + SELECT w.weight + FROM weight_log w + WHERE w.profile_id = c2.profile_id + ORDER BY abs(w.date - c2.date) ASC + LIMIT 1 + ) wl + WHERE c2.body_fat_pct IS NOT NULL + AND (c2.lean_mass IS NULL OR c2.fat_mass IS NULL) +) sub +WHERE c.id = sub.id; diff --git a/backend/routers/caliper.py b/backend/routers/caliper.py index 3c53e52..8245b41 100644 --- a/backend/routers/caliper.py +++ b/backend/routers/caliper.py @@ -14,6 +14,7 @@ from auth import require_auth, check_feature_access, increment_feature_usage from models import CaliperEntry from routers.profiles import get_pid from feature_logger import log_feature_usage +from caliper_composition import enrich_caliper_row_for_response, fill_caliper_body_comp, load_weight_rows router = APIRouter(prefix="/api/caliper", tags=["caliper"]) logger = logging.getLogger(__name__) @@ -27,7 +28,11 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None cur = get_cursor(conn) cur.execute( "SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) - return [r2d(r) for r in cur.fetchall()] + rows = [r2d(r) for r in cur.fetchall()] + weight_rows = load_weight_rows(conn, pid) + for row in rows: + enrich_caliper_row_for_response(row, weight_rows) + return rows @router.post("") @@ -55,6 +60,7 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() d = e.model_dump() + fill_caliper_body_comp(d, conn, pid) is_new_entry = not ex if ex: @@ -86,6 +92,7 @@ def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header pid = get_pid(x_profile_id) with get_db() as conn: d = e.model_dump() + fill_caliper_body_comp(d, conn, pid) cur = get_cursor(conn) cur.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", list(d.values())+[eid,pid]) diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index d4e6998..a0dd4b2 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -21,6 +21,7 @@ from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid from feature_logger import log_feature_usage +from caliper_composition import enrich_caliper_row_for_response, load_weight_rows router = APIRouter(prefix="/api/export", tags=["export"]) logger = logging.getLogger(__name__) @@ -68,10 +69,21 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D details = f"Taille:{float(r['c_waist'])}cm Bauch:{float(r['c_belly'])}cm Hüfte:{float(r['c_hip'])}cm" writer.writerow(["Umfänge", r['date'], "", details]) - # Caliper - cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) - for r in cur.fetchall(): - writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"]) + # Caliper (Magermasse aus Gewicht + KF% nachziehen wenn in DB leer) + cur.execute( + "SELECT date, body_fat_pct, lean_mass, fat_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", + (pid,), + ) + cal_rows = [r2d(r) for r in cur.fetchall()] + weight_rows = load_weight_rows(conn, pid) + for r in cal_rows: + enrich_caliper_row_for_response(r, weight_rows) + if r.get("body_fat_pct") is None: + continue + bf = float(r["body_fat_pct"]) + lm = r.get("lean_mass") + details = f"Magermasse:{float(lm)}kg" if lm is not None else "" + writer.writerow(["Caliper", r["date"], f"{bf}%", details]) # Nutrition cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) @@ -141,6 +153,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) data['insights'] = [r2d(r) for r in cur.fetchall()] + w_rows = [{'date': x['date'], 'weight': x['weight']} for x in data.get('weight', [])] + for row in data.get('caliper', []): + enrich_caliper_row_for_response(row, w_rows) + def decimal_handler(obj): if isinstance(obj, Decimal): return float(obj) diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 65d6777..c9d0157 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -16,6 +16,7 @@ from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid from feature_logger import log_feature_usage +from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) logger = logging.getLogger(__name__) @@ -202,8 +203,12 @@ def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), ses if d in cal_by_date: lm = cal_by_date[d].get('lean_mass') bf = cal_by_date[d].get('body_fat_pct') - row['lean_mass']=float(lm) if lm is not None else None - row['body_fat_pct']=float(bf) if bf is not None else None + if bf is not None and lm is None: + wkg = nearest_weight_kg_from_map(wlog, d) + if wkg is not None: + lm, _fat = compute_lean_fat_kg(wkg, float(bf)) + row['lean_mass'] = float(lm) if lm is not None else None + row['body_fat_pct'] = float(bf) if bf is not None else None result.append(row) return result diff --git a/frontend/src/pages/CaliperScreen.jsx b/frontend/src/pages/CaliperScreen.jsx index e3b2d61..bab3957 100644 --- a/frontend/src/pages/CaliperScreen.jsx +++ b/frontend/src/pages/CaliperScreen.jsx @@ -19,7 +19,6 @@ function emptyForm() { function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) { const sex = profile?.sex||'m' const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30 - const weight = form.weight || 80 const sfPoints = METHOD_POINTS[form.sf_method]?.[sex] || [] const sfVals = {} sfPoints.forEach(k=>{ const v=form[`sf_${k}`]; if(v!==''&&v!=null) sfVals[k]=parseFloat(v) }) @@ -79,7 +78,7 @@ function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Spei