import { useState, useEffect } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useProfile } from '../context/ProfileContext' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine, PieChart, Pie, Cell, ComposedChart } from 'recharts' 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' import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' import RecoveryCharts from '../components/RecoveryCharts' import KpiTilesOverview from '../components/KpiTilesOverview' 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() { const nav = useNavigate() return } function NavToCircum() { const nav = useNavigate() return } function EmptySection({ text, to, toLabel }) { const nav = useNavigate() return (
{text}
{to && }
) } function SectionHeader({ title, to, toLabel, lastUpdated }) { const nav = useNavigate() return (

{title}

{lastUpdated && {dayjs(lastUpdated).format('DD.MM.YY')}} {to && ( )}
) } function RuleCard({ item }) { const [open, setOpen] = useState(false) const color = getStatusColor(item.status) return (
setOpen(o=>!o)}> {item.icon}
{item.category}
{item.title}
{item.value && {item.value}} {open ? : }
{open &&
{item.detail}
}
) } function verdictShort(status) { if (status === 'good') return 'Gut' if (status === 'warn') return 'Hinweis' return 'Achtung' } /** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */ function buildBodyKpiTiles({ summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, 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 trendBits = trendPeriods.length ? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ') : '' const hoverBody = [ 'Gewicht im gewählten Zeitraum (letzter Messwert).', avgAll != null ? `Durchschnitt: ${avgAll} kg` : null, minW != null && maxW != null ? `Min. / Max.: ${minW} – ${maxW} kg` : null, trendBits ? `Änderung: ${trendBits}` : null, goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null, ].filter(Boolean).join('\n') tiles.push({ key: 'weight', category: 'Gewicht', icon: '⚖️', value: `${summary.weight_kg} kg`, sublabel: dataPoints ? `${dataPoints} Messwerte` : '', verdict: vs, status: st, hoverTop: 'Gewicht', hoverBody, keys: ['weight_aktuell', 'weight_trend'], }) } const kfRule = rules.find(r => r.category === 'Körperfett') if (summary.body_fat_pct != null) { tiles.push({ key: 'bf', category: 'Körperfett', icon: '🫧', value: `${summary.body_fat_pct}%`, valueColor: bfCat?.color, sublabel: bfCat?.label || summary.bf_category_label || '', verdict: verdictShort(kfRule?.status || 'good'), status: kfRule?.status || 'good', hoverTop: kfRule?.title || 'Körperfettanteil', hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } const mmRule = rules.find(r => r.category === 'Muskelmasse') if (summary.lean_mass_kg != null || summary.ffmi != null) { const valParts = [] if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`) if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`) tiles.push({ key: 'lean_ffmi', category: 'Magermasse', icon: '💪', value: valParts.join(' · ') || '—', sublabel: 'Lean / FFMI', verdict: mmRule ? verdictShort(mmRule.status) : '—', status: mmRule?.status || 'good', hoverTop: mmRule?.title || 'Muskelmasse', hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } const bmiRule = rules.find(r => r.category === 'BMI') if (bmiRule) { tiles.push({ key: 'bmi', category: 'BMI', icon: '📋', value: bmiRule.value || '—', sublabel: 'Body-Mass-Index', verdict: verdictShort(bmiRule.status), status: bmiRule.status, hoverTop: bmiRule.title, hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } const whrRule = rules.find(r => r.category === 'Fettverteilung') if (summary.whr != null) { const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85) 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'), }) } const whtrRule = rules.find(r => r.category === 'Taille/Größe') if (summary.whtr != null) { const ok = summary.whtr < 0.5 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'), }) } const lastRule = rules.find(r => r.category.startsWith('Seit letzter')) if (lastRule) { tiles.push({ key: 'delta', category: 'Messvergleich', icon: '📊', value: lastRule.value || '—', sublabel: 'seit Vorperiode', verdict: verdictShort(lastRule.status), status: lastRule.status, hoverTop: lastRule.title, hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), }) } return tiles } function NutritionGoalsStrip({ grouped }) { const nav = useNavigate() const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4) if (!goals.length) return null return (
Ernährungsbezogene Ziele
{goals.map(g => (
{g.name || g.label_de || g.goal_type}
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
))}
) } function BodyGoalsStrip({ grouped }) { const nav = useNavigate() const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4) if (!goals.length) return null return (
Körperbezogene Ziele
{goals.map(g => (
{g.name || g.label_de || g.goal_type}
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
))}
) } function InsightBox({ insights, slugs, onRequest, loading }) { const [expanded, setExpanded] = useState(null) const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung', aktivitaet:'Fitness',gesundheit:'Gesundheit',ziele:'Ziele', pipeline:'🔬 Mehrstufige Analyse', pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung', pipeline_activity:'Pipeline Fitness',pipeline_synthesis:'Pipeline Synthese', pipeline_goals:'Pipeline Ziele'} return (
🤖 KI-AUSWERTUNGEN
{slugs.map(slug=>( ))}
{relevant.length===0 && (
Noch keine Auswertung. Klicke oben um eine zu erstellen.
)} {relevant.map(ins=>(
setExpanded(expanded===ins.id?null:ins.id)}>
{dayjs(ins.created).format('DD. MMM YYYY, HH:mm')} · {LABELS[ins.scope]||ins.scope}
{expanded===ins.id?:}
{expanded===ins.id &&
}
))}
) } // ── Period selector ─────────────────────────────────────────────────────────── function PeriodSelector({ value, onChange }) { const opts = [{v:30,l:'30 Tage'},{v:90,l:'90 Tage'},{v:180,l:'6 Monate'},{v:365,l:'1 Jahr'},{v:9999,l:'Alles'}] return (
{opts.map(o=>( ))}
) } // ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ── function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(90) const [groupedGoals, setGroupedGoals] = useState(null) const [viz, setViz] = useState(null) const [vizLoading, setVizLoading] = useState(true) const [vizError, setVizError] = useState(null) const sex = profile?.sex || 'm' useEffect(() => { let cancelled = false api.listGoalsGrouped() .then(g => { if (!cancelled) setGroupedGoals(g) }) .catch(() => { if (!cancelled) setGroupedGoals({}) }) return () => { cancelled = true } }, []) useEffect(() => { let cancelled = false setVizLoading(true) setVizError(null) api.getBodyHistoryViz(period) .then(data => { if (!cancelled) { setViz(data) setVizLoading(false) } }) .catch(e => { if (!cancelled) { setVizError(e.message || 'Laden fehlgeschlagen') setVizLoading(false) } }) return () => { cancelled = true } }, [period]) const w = viz?.weight const cal = viz?.caliper const circ = viz?.circumference const summary = viz?.summary || {} const wCd = (w?.series || []).map(row => ({ date: fmtDate(row.date), weight: row.weight, avg7: row.avg7, avg14: row.avg14, })) const hasWeight = (w?.data_points || 0) >= 2 const avgAll = w?.overall_avg_kg const minW = w?.min_kg const maxW = w?.max_kg const trendPeriods = w?.trend_periods || [] const bfCd = (cal?.series || []).map(s => ({ date: fmtDate(s.date), bf: s.body_fat_pct, })) const propChartData = (circ?.proportion_series || []).map(p => ({ date: fmtDate(p.date), vTaper: p.v_taper_cm, vTaper_avg: p.v_taper_cm_avg, belly: p.belly_cm, })) const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined) const idxSeriesRaw = circ?.index_series || [] const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) })) const idxOk = circ?.index_usable const cirCd = (circ?.fallback_multiline || []).map(r => ({ date: fmtDate(r.date), waist: r.waist, hip: r.hip, 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 const rules = (viz?.interpretation_tiles || []).map(t => ({ category: t.category, icon: t.icon, status: t.status, title: t.title, detail: t.detail, value: t.value, related_placeholder_keys: t.related_placeholder_keys, })) const kpiTiles = buildBodyKpiTiles({ summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints: w?.data_points, sex, bfCat, goalW, }) const hasAnyData = (w?.data_points > 0) || (cal?.data_points > 0) || (cirCd.length > 0) if (vizLoading && !viz) { return (
) } if (vizError) { return (
{vizError}
) } if (!hasAnyData) { return (
) } return (

Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Fitness.

{viz?.meta?.layer_2a_alignment && (
{viz.meta.layer_2a_alignment}
)} {vizLoading && (
Aktualisiere…
)} {hasWeight && (
Gewicht · {w?.data_points || 0} Einträge
{avgAll != null && ( )} {goalW != null && ( )} [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
● Täglich Ø 7T Ø 14T Ø Gesamt
)} {bfCd.length >= 2 && (
Körperfett (Caliper)
[`${v}%`, 'KF%']} /> {goalBf != null && }
Magermasse aus Gewicht und KF% — zweite Kurve entfällt.
)} {propChartData.length >= 2 && (
Silhouette & Proportion
V-Taper (Brust − Taille) in cm. {showBellyOnProp && <> Bauch (rechte Achse).}
{showBellyOnProp && } { if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust − Taille'] if (name === 'belly') return [`${v} cm`, 'Bauch'] return [v, name] }} /> {showBellyOnProp && }
Brust − Taille gleitender Mittelwert {showBellyOnProp && Bauch (cm)}
)} {idxOk && (
Relative Entwicklung der Umfänge
Index 100 = erste Messung im Zeitraum.
[`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} /> {idxSeries.some(d => d.chest_idx != null) && } {idxSeries.some(d => d.waist_idx != null) && } {idxSeries.some(d => d.belly_idx != null) && }
Brust Taille Bauch
)} {propChartData.length < 2 && cirCd.length >= 2 && (
Umfänge (Taille / Hüfte / Bauch)
Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.
[`${v} cm`, n]} /> {cirCd.some(d => d.belly) && }
)}
) } /** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */ function kcalVsWeightKcalDomain(points, tdeeRef) { const vals = (points || []) .map(d => Number(d.kcal_avg)) .filter(v => !Number.isNaN(v)) if (!vals.length) return ['auto', 'auto'] let lo = Math.min(...vals) let hi = Math.max(...vals) const t = tdeeRef != null ? Number(tdeeRef) : NaN if (!Number.isNaN(t)) { lo = Math.min(lo, t) hi = Math.max(hi, t) } const span = hi - lo || 400 const pad = Math.max(100, span * 0.1) return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)] } const TDEE_REF_LINE_COLOR = '#475569' /** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */ function KcalVsWeightLegend({ showTdee }) { const line = (color) => ({ display: 'inline-block', width: 22, height: 3, background: color, borderRadius: 1, verticalAlign: 'middle', marginRight: 6, }) return (
Ø Kalorien (7-Tage-Mittel) Gewicht (kg) {showTdee ? ( TDEE-Referenz (geschätzt) ) : null}
) } /** 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) 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`}
) } 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) 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']} />
TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
) } // ── Nutrition Section ───────────────────────────────────────────────────────── /** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) const [groupedGoals, setGroupedGoals] = useState(null) const [viz, setViz] = useState(null) const [vizLoad, setVizLoad] = useState(true) const [vizErr, setVizErr] = useState(null) useEffect(() => { let cancelled = false api.listGoalsGrouped() .then(g => { if (!cancelled) setGroupedGoals(g) }) .catch(() => { if (!cancelled) setGroupedGoals({}) }) return () => { cancelled = true } }, []) useEffect(() => { let cancelled = false setViz(null) setVizLoad(true) setVizErr(null) const daysReq = period === 9999 ? 9999 : period api.getNutritionHistoryViz(daysReq) .then(v => { if (!cancelled) setViz(v) }) .catch(e => { if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen') }) .finally(() => { if (!cancelled) setVizLoad(false) }) return () => { cancelled = true } }, [period]) if (vizLoad) { return (
) } if (vizErr) { return (
{vizErr}
) } if (!viz?.has_nutrition_entries) { return ( ) } const summary = viz.summary || {} const n = Math.max(0, Number(summary.data_points) || 0) const avgKcal = Math.round(Number(summary.kcal_avg) || 0) const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0) const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period) const kpiTiles = (viz.kpi_tiles || []).map(t => ({ ...t, sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel, })) const pieData = viz.donut_avg_pct || [] const cdMacro = (viz.daily_macros || []).map(d => ({ date: fmtDate(d.date), Protein: d.Protein, KH: d.KH, Fett: d.Fett, kcal: d.kcal, })) const weeklyMacro = viz.weekly_macro_chart const wmLoading = false const wmError = null if (!cdMacro.length || n === 0) { return (
) } return (

Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '} Kalorien vs. Gewicht und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).

Makroverteilung täglich (g) · Fokus Protein
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
{ptLow > 0 && ( )} [`${v}g`, name]} />
Protein (unten) Fett (Mitte) KH (oben)
Ø Makro-Quote ({n} Tage)
{pieData.length > 0 ? (
{pieData.map((e, i) => ( ))} [`${v}%`, name]} />
{pieData.map(p => { const fill = macroFillByName(p.name) return (
{p.name}
{p.value}%
{p.grams != null ? `${p.grams}g` : '—'}
) })}
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
) : (
Keine Makro-Mittelwerte im Zeitraum.
)}
Wöchentliche Makro-Verteilung (Backend)
Zeitverläufe (Energie & Protein)
) } // ── Activity Section — nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts ─ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { const [period, setPeriod] = useState(30) const actList = activities || [] const hasList = actList.length > 0 return (

Auswertung ausschließlich aus dem Fitness-Bundle (Data-Layer / Issue 53). Zeitraum-Buttons steuern dasselbe Fenster wie die API.

{hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
{globalQualityLevel === 'quality' && '✓ Filter: Hochwertig (excellent, good, acceptable)'} {globalQualityLevel === 'very_good' && '✓✓ Filter: Sehr gut (excellent, good)'} {globalQualityLevel === 'excellent' && '⭐ Filter: Exzellent (nur excellent)'} Hier ändern →
)} {hasList ? ( ) : null}
) } // ── Correlation Section ─────────────────────────────────────────────────────── function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) { const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight) if (filtered.length < 5) return ( ) const sex = profile?.sex||'m' const height = profile?.height||178 const latestW = filtered[filtered.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) // light activity baseline // Protein vs Lean Mass (only days with both) const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass) .map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass})) // Chart 3: Activity kcal vs Weight change const actVsW = filtered.filter(d=>d.weight) .map((d,i,arr)=>{ const prev = arr[i-1] return { date: fmtDate(d.date), weight: d.weight, weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null, kcal: d.kcal||0, } }).filter(d=>d.weightDelta!==null) // Chart 4: Calorie balance (intake - estimated TDEE) const balance = filtered.map(d=>({ date: fmtDate(d.date), balance: Math.round((d.kcal||0) - tdee), })) const balWithAvg = rollingAvg(balance,'balance') const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length) // ── Correlation insights ── const corrInsights = [] // 1. Kcal → Weight correlation if (filtered.length >= 14) { const highKcal = filtered.filter(d=>d.kcal>tdee+200) const lowKcal = filtered.filter(d=>d.kcal=3 && lowKcal.length>=3) { const avgWHigh = Math.round(highKcal.reduce((s,d)=>s+d.weight,0)/highKcal.length*10)/10 const avgWLow = Math.round(lowKcal.reduce((s,d)=>s+d.weight,0)/lowKcal.length*10)/10 corrInsights.push({ icon:'📊', status: avgWLow < avgWHigh ? 'good' : 'warn', title: avgWLow < avgWHigh ? `Kalorienreduktion wirkt: Ø ${avgWLow}kg bei Defizit vs. ${avgWHigh}kg bei Überschuss` : `Kein klarer Kalorieneffekt auf Gewicht erkennbar`, detail: `Tage mit Überschuss (>${tdee+200} kcal): Ø ${avgWHigh}kg · Tage mit Defizit (<${tdee-200} kcal): Ø ${avgWLow}kg`, }) } } // 2. Protein → Lean mass if (protVsLean.length >= 3) { const ptLow = Math.round(latestW*1.6) const highProt = protVsLean.filter(d=>d.protein>=ptLow) const lowProt = protVsLean.filter(d=>d.protein=2 && lowProt.length>=2) { const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10 const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10 corrInsights.push({ icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn', title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`, detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`, }) } } // 3. Avg balance corrInsights.push({ icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️', status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good', title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`, detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${ avgBalance<-500?'Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen.': avgBalance<-100?'Moderates Defizit – ideal für Fettabbau bei Muskelerhalt.': avgBalance>300?'Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich.': 'Nahezu ausgeglichen – Gewicht sollte stabil bleiben.'}`, }) return (

Das Diagramm Kalorien (Ø 7T) vs. Gewicht liegt unter Verlauf → Ernährung (gleiche Datenbasis).

{/* Chart: Calorie balance */}
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
{/* Chart 3: Protein vs Lean Mass */} {protVsLean.length >= 3 && (
🥩 Protein vs. Magermasse
[`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/>
— Protein g/Tag · ● Magermasse kg
)} {/* Correlation insights */} {corrInsights.length > 0 && (
KORRELATIONSAUSSAGEN
{corrInsights.map((item,i) => (
{item.icon}
{item.title}
{item.detail}
))}
ℹ️ TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
)}
) } // ── Photo Grid ──────────────────────────────────────────────────────────────── function PhotoGrid() { const [photos, setPhotos] = useState([]) const [big, setBig] = useState(null) const load = () => api.listPhotos().then(setPhotos) useEffect(() => { load() }, []) const handleDelete = async (id) => { if (!confirm('Dieses Foto löschen?')) return try { await api.deletePhoto(id) if (big === id) setBig(null) await load() } catch (e) { alert(e.message || 'Löschen fehlgeschlagen') } } if (!photos.length) { return } const sorted = [...photos].sort((a, b) => photoSortKey(b).localeCompare(photoSortKey(a))) const byMonth = new Map() for (const p of sorted) { const mk = photoMonthKey(p) if (!byMonth.has(mk)) byMonth.set(mk, []) byMonth.get(mk).push(p) } const monthKeys = [...byMonth.keys()].sort((a, b) => b.localeCompare(a)) return ( <> {big && (
setBig(null)} role="presentation" >
)} {monthKeys.map((mk) => (
{dayjs(`${mk}-01`).format('MMMM YYYY')}
{byMonth.get(mk).map((p) => (
setBig(p.id)} alt="" />
{formatPhotoCaption(p)}
))}
))} ) } // ── Main ────────────────────────────────────────────────────────────────────── // ── Recovery Section ────────────────────────────────────────────────────────── function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(28) return (
Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick.
{/* Recovery Charts (Phase 0c) */}
) } const TABS = [ { id:'body', label:'⚖️ Körper' }, { id:'nutrition', label:'🍽️ Ernährung' }, { id:'activity', label:'🏋️ Fitness' }, { id:'recovery', label:'😴 Erholung' }, { id:'correlation', label:'🔗 Korrelation' }, { id:'photos', label:'📷 Fotos' }, ] export default function History() { const { activeProfile } = useProfile() // Issue #31: Get global quality filter const location = useLocation?.() || {} const [tab, setTab] = useState((location.state?.tab)||'body') const [weights, setWeights] = useState([]) const [calipers, setCalipers] = useState([]) const [circs, setCircs] = useState([]) const [nutrition, setNutrition] = useState([]) const [activities, setActivities] = useState([]) const [corrData, setCorrData] = useState([]) const [insights, setInsights] = useState([]) const [prompts, setPrompts] = useState([]) const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) const [loadingSlug,setLoadingSlug]= useState(null) const loadAll = () => Promise.all([ api.listWeight(365), api.listCaliper(), api.listCirc(), api.listNutrition(90), api.listActivity(25_000), api.nutritionCorrelations(), api.latestInsights(), api.getProfile(), api.listPrompts(), ]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{ setWeights(w); setCalipers(ca); setCircs(ci) setNutrition(n); setActivities(a); setCorrData(corr) setInsights(Array.isArray(ins)?ins:[]); setProfile(p) setPrompts(Array.isArray(pr)?pr:[]) setLoading(false) }) useEffect(() => { loadAll() }, [activeProfile?.quality_filter_level]) useEffect(() => { const t = location.state?.tab if (t && TABS.some(x => x.id === t)) setTab(t) }, [location.state?.tab]) const requestInsight = async (slug) => { setLoadingSlug(slug) try { const result = await api.runInsight(slug) // result is already JSON, not a Response object const ins = await api.latestInsights() setInsights(Array.isArray(ins)?ins:[]) } catch(e){ alert('KI-Fehler: '+e.message) } finally{ setLoadingSlug(null) } } if(loading) return
// Filter active prompts const activeSlugs = prompts.filter(p=>p.active).map(p=>p.slug) const filterActiveSlugs = (slugs) => slugs.filter(s=>activeSlugs.includes(s)) const sp={insights,onRequest:requestInsight,loadingSlug,filterActiveSlugs} return (

Verlauf & Auswertung

{tab==='body' && } {tab==='nutrition' && } {tab==='activity' && } {tab==='recovery' && } {tab==='correlation' && } {tab==='photos' && }
) }