feat: Integrate caliper data enrichment and weight loading in API responses
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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.
This commit is contained in:
Lars 2026-04-06 06:08:37 +02:00
parent 00437a92ab
commit 49e9c9c214
6 changed files with 178 additions and 19 deletions

View File

@ -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

View File

@ -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;

View File

@ -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])

View File

@ -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)

View File

@ -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,6 +203,10 @@ 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')
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)

View File

@ -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
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>onSave(bfPct, sex)}
onClick={()=>onSave(bfPct)}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
@ -117,22 +116,19 @@ export default function CaliperScreen() {
loadUsage()
},[])
const buildPayload = (f, bfPct, sex) => {
const weight = profile?.weight || null
const buildPayload = (f, bfPct) => {
const payload = { date: f.date, sf_method: f.sf_method, notes: f.notes }
Object.entries(f).forEach(([k,v])=>{ if(k.startsWith('sf_')&&v!==''&&v!=null) payload[k]=parseFloat(v) })
if(bfPct!=null) {
payload.body_fat_pct = bfPct
// get latest weight from profile or skip lean/fat
}
if (bfPct != null) payload.body_fat_pct = bfPct
// lean_mass / fat_mass: Backend leitet aus nächstem weight_log zum Messdatum ab
return payload
}
const handleSave = async (bfPct, sex) => {
const handleSave = async (bfPct) => {
setSaving(true)
setError(null)
try {
const payload = buildPayload(form, bfPct, sex)
const payload = buildPayload(form, bfPct)
await api.upsertCaliper(payload)
setSaved(true)
await load()
@ -148,8 +144,8 @@ export default function CaliperScreen() {
}
}
const handleUpdate = async (bfPct, sex) => {
const payload = buildPayload(editing, bfPct, sex)
const handleUpdate = async (bfPct) => {
const payload = buildPayload(editing, bfPct)
await api.updateCaliper(editing.id, payload)
setEditing(null); await load()
}