Compare commits
No commits in common. "75736dadece5a2da717e72d7201c2e399ca261d4" and "8d56c352fc50426dcfd9108273bd675900fe2b13" have entirely different histories.
75736dadec
...
8d56c352fc
|
|
@ -1,111 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- 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,7 +14,6 @@ from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from models import CaliperEntry
|
from models import CaliperEntry
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
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"])
|
router = APIRouter(prefix="/api/caliper", tags=["caliper"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -28,11 +27,7 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
|
"SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
return [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("")
|
@router.post("")
|
||||||
|
|
@ -60,7 +55,6 @@ 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))
|
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||||
ex = cur.fetchone()
|
ex = cur.fetchone()
|
||||||
d = e.model_dump()
|
d = e.model_dump()
|
||||||
fill_caliper_body_comp(d, conn, pid)
|
|
||||||
is_new_entry = not ex
|
is_new_entry = not ex
|
||||||
|
|
||||||
if ex:
|
if ex:
|
||||||
|
|
@ -92,7 +86,6 @@ def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
d = e.model_dump()
|
d = e.model_dump()
|
||||||
fill_caliper_body_comp(d, conn, pid)
|
|
||||||
cur = get_cursor(conn)
|
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",
|
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])
|
list(d.values())+[eid,pid])
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
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"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -69,21 +68,10 @@ 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"
|
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])
|
writer.writerow(["Umfänge", r['date'], "", details])
|
||||||
|
|
||||||
# Caliper (Magermasse aus Gewicht + KF% nachziehen wenn in DB leer)
|
# Caliper
|
||||||
cur.execute(
|
cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
"SELECT date, body_fat_pct, lean_mass, fat_mass FROM caliper_log WHERE profile_id=%s ORDER BY date",
|
for r in cur.fetchall():
|
||||||
(pid,),
|
writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"])
|
||||||
)
|
|
||||||
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
|
# Nutrition
|
||||||
cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
|
cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
|
|
@ -153,10 +141,6 @@ 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,))
|
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||||
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
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):
|
def decimal_handler(obj):
|
||||||
if isinstance(obj, Decimal):
|
if isinstance(obj, Decimal):
|
||||||
return float(obj)
|
return float(obj)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
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"])
|
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -203,12 +202,8 @@ def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), ses
|
||||||
if d in cal_by_date:
|
if d in cal_by_date:
|
||||||
lm = cal_by_date[d].get('lean_mass')
|
lm = cal_by_date[d].get('lean_mass')
|
||||||
bf = cal_by_date[d].get('body_fat_pct')
|
bf = cal_by_date[d].get('body_fat_pct')
|
||||||
if bf is not None and lm is None:
|
row['lean_mass']=float(lm) if lm is not None else None
|
||||||
wkg = nearest_weight_kg_from_map(wlog, d)
|
row['body_fat_pct']=float(bf) if bf is not None else None
|
||||||
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)
|
result.append(row)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ function emptyForm() {
|
||||||
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
||||||
const sex = profile?.sex||'m'
|
const sex = profile?.sex||'m'
|
||||||
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
|
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 sfPoints = METHOD_POINTS[form.sf_method]?.[sex] || []
|
||||||
const sfVals = {}
|
const sfVals = {}
|
||||||
sfPoints.forEach(k=>{ const v=form[`sf_${k}`]; if(v!==''&&v!=null) sfVals[k]=parseFloat(v) })
|
sfPoints.forEach(k=>{ const v=form[`sf_${k}`]; if(v!==''&&v!=null) sfVals[k]=parseFloat(v) })
|
||||||
|
|
@ -78,7 +79,7 @@ function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Spei
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
|
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||||
onClick={()=>onSave(bfPct)}
|
onClick={()=>onSave(bfPct, sex)}
|
||||||
disabled={saving || (usage && !usage.allowed)}
|
disabled={saving || (usage && !usage.allowed)}
|
||||||
>
|
>
|
||||||
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
|
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
|
||||||
|
|
@ -116,19 +117,22 @@ export default function CaliperScreen() {
|
||||||
loadUsage()
|
loadUsage()
|
||||||
},[])
|
},[])
|
||||||
|
|
||||||
const buildPayload = (f, bfPct) => {
|
const buildPayload = (f, bfPct, sex) => {
|
||||||
|
const weight = profile?.weight || null
|
||||||
const payload = { date: f.date, sf_method: f.sf_method, notes: f.notes }
|
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) })
|
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
|
if(bfPct!=null) {
|
||||||
// lean_mass / fat_mass: Backend leitet aus nächstem weight_log zum Messdatum ab
|
payload.body_fat_pct = bfPct
|
||||||
|
// get latest weight from profile or skip lean/fat
|
||||||
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (bfPct) => {
|
const handleSave = async (bfPct, sex) => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload(form, bfPct)
|
const payload = buildPayload(form, bfPct, sex)
|
||||||
await api.upsertCaliper(payload)
|
await api.upsertCaliper(payload)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
await load()
|
await load()
|
||||||
|
|
@ -144,8 +148,8 @@ export default function CaliperScreen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (bfPct) => {
|
const handleUpdate = async (bfPct, sex) => {
|
||||||
const payload = buildPayload(editing, bfPct)
|
const payload = buildPayload(editing, bfPct, sex)
|
||||||
await api.updateCaliper(editing.id, payload)
|
await api.updateCaliper(editing.id, payload)
|
||||||
setEditing(null); await load()
|
setEditing(null); await load()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user