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.
This commit is contained in:
parent
00437a92ab
commit
49e9c9c214
111
backend/caliper_composition.py
Normal file
111
backend/caliper_composition.py
Normal 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
|
||||
24
backend/migrations/035_caliper_lean_mass_backfill.sql
Normal file
24
backend/migrations/035_caliper_lean_mass_backfill.sql
Normal 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;
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user