diff --git a/backend/data_layer/body_viz.py b/backend/data_layer/body_viz.py index 558490c..1820ea5 100644 --- a/backend/data_layer/body_viz.py +++ b/backend/data_layer/body_viz.py @@ -48,6 +48,31 @@ def _iso(d: Any) -> Optional[str]: return str(d)[:10] +def _weight_trend_kpi(trend_periods: List[Dict[str, Any]]) -> Dict[str, str]: + """ + Kurzurteil Gewichtstrend (Schwelle ±0,25 kg, Priorität 90T → 30T → erste Periode). + Eine Quelle mit dem Verlauf-Bundle — kein paralleles Frontend-Routing mehr. + """ + if not trend_periods: + return {"verdict": "Stabil", "status": "good"} + t90 = next((t for t in trend_periods if t.get("label") == "90T"), None) + t30 = next((t for t in trend_periods if t.get("label") == "30T"), None) + d: Optional[float] = None + if t90 is not None and t90.get("diff_kg") is not None: + d = float(t90["diff_kg"]) + elif t30 is not None and t30.get("diff_kg") is not None: + d = float(t30["diff_kg"]) + elif trend_periods[0].get("diff_kg") is not None: + d = float(trend_periods[0]["diff_kg"]) + else: + return {"verdict": "Stabil", "status": "good"} + if d < -0.25: + return {"verdict": "Trend ↓", "status": "good"} + if d > 0.25: + return {"verdict": "Trend ↑", "status": "warn"} + return {"verdict": "Stabil", "status": "good"} + + def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: """ Returns chart-ready series and interpretation tiles for the body history tab. @@ -437,6 +462,7 @@ def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: "min_kg": min_w, "max_kg": max_w, "trend_periods": trend_periods, + "trend_kpi": _weight_trend_kpi(trend_periods), "data_points": len(w_points), "related_placeholder_keys": [ "weight_aktuell", diff --git a/backend/version.py b/backend/version.py index 2e03bfe..8e3e8c1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -7,7 +7,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH - PATCH: Bugfix, kleine Änderung, Refactor """ -APP_VERSION = "0.9r" +APP_VERSION = "0.9s" BUILD_DATE = "2026-04-20" DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import @@ -36,6 +36,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.9s", + "date": "2026-04-20", + "changes": [ + "Phase B: body-history-viz weight.trend_kpi (Gewichtstrend-Urteil im data_layer/body_viz)", + "History Körper-KPIs: keine Client-Schwellen für WHR/WHtR; KF%-Farbe über Interpretations-Status", + "Kcal vs. Gewicht: kein Frontend-TDEE-Fallback; Hinweis bei <5 gemeinsamen Tagen", + ], + }, { "version": "0.9r", "date": "2026-04-20", diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index fff5c14..09a5894 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -10,7 +10,6 @@ import { import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' -import { getBfCategory } from '../utils/calc' import { getStatusColor, getStatusBg } from '../utils/interpret' import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' @@ -22,12 +21,6 @@ import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') -function rollingAvg(arr, key, window=7) { - return arr.map((d,i) => { - const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null) - return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d - }) -} const fmtDate = d => dayjs(d).format('DD.MM') function NavToCaliper() { @@ -94,23 +87,14 @@ function verdictShort(status) { return 'Achtung' } -/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */ +/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln — Trend-Urteil aus Bundle ``weight.trend_kpi`` (Layer 1). */ function buildBodyKpiTiles({ - summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, goalW, + summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW, }) { const tiles = [] if (summary.weight_kg != null) { - const t90 = trendPeriods.find(t => t.label === '90T') - const t30 = trendPeriods.find(t => t.label === '30T') - const d = t90?.diff_kg ?? t30?.diff_kg ?? trendPeriods[0]?.diff_kg - let st = 'good' - let vs = 'Stabil' - if (d != null) { - if (d < -0.25) { st = 'good'; vs = 'Trend ↓' } - else if (d > 0.25) { st = 'warn'; vs = 'Trend ↑' } - else { st = 'good'; vs = 'Stabil' } - } + const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' } const trendBits = trendPeriods.length ? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ') : '' @@ -128,8 +112,8 @@ function buildBodyKpiTiles({ icon: '⚖️', value: `${summary.weight_kg} kg`, sublabel: dataPoints ? `${dataPoints} Messwerte` : '', - verdict: vs, - status: st, + verdict: wt.verdict, + status: wt.status, hoverTop: 'Gewicht', hoverBody, keys: ['weight_aktuell', 'weight_trend'], @@ -143,8 +127,8 @@ function buildBodyKpiTiles({ category: 'Körperfett', icon: '🫧', value: `${summary.body_fat_pct}%`, - valueColor: bfCat?.color, - sublabel: bfCat?.label || summary.bf_category_label || '', + valueColor: kfRule ? getStatusColor(kfRule.status) : undefined, + sublabel: summary.bf_category_label || '', verdict: verdictShort(kfRule?.status || 'good'), status: kfRule?.status || 'good', hoverTop: kfRule?.title || 'Körperfettanteil', @@ -186,34 +170,32 @@ function buildBodyKpiTiles({ } const whrRule = rules.find(r => r.category === 'Fettverteilung') - if (summary.whr != null) { - const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85) + if (summary.whr != null && whrRule) { tiles.push({ key: 'whr', category: 'Fettverteilung', icon: '📐', value: String(summary.whr), sublabel: 'WHR · Taille ÷ Hüfte', - verdict: whrRule ? verdictShort(whrRule.status) : (ok ? 'Gut' : 'Hinweis'), - status: whrRule?.status || (ok ? 'good' : 'warn'), - hoverTop: whrRule?.title || 'Waist-Hip-Ratio', - hoverBody: [whrRule?.detail, !whrRule && `Ziel unter ${sex === 'm' ? '0,90' : '0,85'}.`, whrRule?.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + verdict: verdictShort(whrRule.status), + status: whrRule.status, + hoverTop: whrRule.title || 'Waist-Hip-Ratio', + hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } const whtrRule = rules.find(r => r.category === 'Taille/Größe') - if (summary.whtr != null) { - const ok = summary.whtr < 0.5 + if (summary.whtr != null && whtrRule) { tiles.push({ key: 'whtr', category: 'Taille/Größe', icon: '📏', value: String(summary.whtr), sublabel: 'WHtR · Taille ÷ Größe', - verdict: whtrRule ? verdictShort(whtrRule.status) : (ok ? 'Gut' : 'Hinweis'), - status: whtrRule?.status || (ok ? 'good' : 'warn'), - hoverTop: whtrRule?.title || 'Waist-to-Height-Ratio', - hoverBody: [whtrRule?.detail, !whtrRule && 'Ziel unter 0,50 (WHO).', whtrRule?.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + verdict: verdictShort(whtrRule.status), + status: whtrRule.status, + hoverTop: whtrRule.title || 'Waist-to-Height-Ratio', + hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } @@ -399,8 +381,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl const [vizLoading, setVizLoading] = useState(true) const [vizError, setVizError] = useState(null) - const sex = profile?.sex || 'm' - useEffect(() => { let cancelled = false api.listGoalsGrouped() @@ -470,7 +450,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl belly: r.belly, })) - const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct @@ -492,8 +471,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl maxW, avgAll, dataPoints: w?.data_points, - sex, - bfCat, + weightTrendKpi: w?.trend_kpi, goalW, }) @@ -785,75 +763,31 @@ function KcalVsWeightLegend({ showTdee }) { ) } -/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */ -function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) { - if (vizKcalWeight?.points?.length >= 5) { - const tdee = vizKcalWeight.tdee_reference_kcal - const kcalVsW = vizKcalWeight.points.map(d => ({ - ...d, - date: fmtDate(d.date), - })) - const n = vizKcalWeight.common_days_count ?? kcalVsW.length - const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null - const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) +/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics); kein Frontend-TDEE-Fallback. */ +function KcalVsWeightChart({ vizKcalWeight }) { + const n = vizKcalWeight?.points?.length ?? 0 + if (n < 5) { + if (n === 0) return null return ( -
+
Kalorien (Ø 7 Tage) vs. Gewicht
-
- Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. -
- - - - - - - [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} - /> - {tdeeLabel != null && ( - - )} - - - - - -
- {tdeeLabel != null - ? `TDEE ~${tdeeLabel} kcal · ${n} gemeinsame Tage` - : `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`} +
+ Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum).
) } - const raw = (corrRows || []).filter(d => { - if (!d.kcal || d.weight == null) return false - const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD') - return allTime || ds >= cutoffDate - }) - if (raw.length < 5) return null - - const sex = profile?.sex || 'm' - const height = profile?.height || 178 - const latestW = raw[raw.length - 1]?.weight || 80 - const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35 - const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161 - const tdee = Math.round(bmr * 1.4) - const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal') - const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee) - + const tdee = vizKcalWeight.tdee_reference_kcal + const kcalVsW = vizKcalWeight.points.map(d => ({ + ...d, + date: fmtDate(d.date), + })) + const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length + const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) return (
@@ -866,27 +800,31 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD - + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} - /> - [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} /> + {tdeeLabel != null && ( + + )} - +
- TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage + {tdeeLabel != null + ? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage` + : `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
) @@ -894,7 +832,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD // ── Nutrition Section ───────────────────────────────────────────────────────── /** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */ -function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { +function NutritionSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) const [groupedGoals, setGroupedGoals] = useState(null) const [viz, setViz] = useState(null) @@ -1000,13 +938,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct - + {balDaily.length > 0 && tdeeRef != null && (
@@ -1813,7 +1745,7 @@ export default function History() {
{tab==='overview' && } {tab==='body' && } - {tab==='nutrition' && } + {tab==='nutrition' && } {tab==='activity' && } {tab==='photos' && }