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,
ScatterChart, Scatter,
} 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 RecoveryDashboardOverview from '../components/RecoveryDashboardOverview'
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 nav('/caliper')}>Caliper-Daten
}
function NavToCircum() {
const nav = useNavigate()
return nav('/circum')}>Umfang-Daten
}
function EmptySection({ text, to, toLabel }) {
const nav = useNavigate()
return (
🤖 KI-AUSWERTUNGEN
{slugs.map(slug=>(
onRequest(slug)} disabled={loading===slug}>
{loading===slug
?
: <> {LABELS[slug]||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=>(
onChange(o.v)}
style={{padding:'4px 10px',borderRadius:12,fontSize:11,fontWeight:500,border:'1.5px solid',
cursor:'pointer',fontFamily:'var(--font)',
background:value===o.v?'var(--accent)':'transparent',
borderColor:value===o.v?'var(--accent)':'var(--border2)',
color:value===o.v?'white':'var(--text2)'}}>
{o.l}
))}
)
}
// ── 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 (
)
}
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
{ window.location.href = '/weight' }}>
Daten
{avgAll != null && (
)}
{goalW != null && (
)}
[`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
● Täglich
Ø 7T
Ø 14T
Ø Gesamt
)}
{bfCd.length >= 2 && (
[`${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 (
)
}
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
const balDaily = viz.calorie_balance_daily || []
const plm = viz.protein_vs_lean_mass || {}
const plmPts = plm.points || []
const nutHeur = viz.nutrition_correlation_heuristics || []
const tdeeRef = viz.tdee_reference_kcal
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).
{' '}
Kalorienbilanz , Protein vs. Magermasse und den Block{' '}
«Kurz-Einordnung» finden Sie hier — früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
{balDaily.length > 0 && tdeeRef != null && (
Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal)
Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
({ ...d, date: fmtDate(d.date) }))}
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
>
[`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
/>
)}
{plmPts.length >= 3 && (
Protein vs. Magermasse (Caliper, forward-filled)
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
{plm.protein_target_low_g > 0 && (
)}
[`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']}
/>
)}
{nutHeur.length > 0 && (
Ernährung — Kurz-Einordnung
{nutHeur.map((item, i) => (
{item.icon || '•'}
{item.title}
{item.detail}
))}
)}
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 (
Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.
Erholung (Schlaf, HRV, Vitalwerte)
{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 →
)}
)
}
function overviewSectionTone(sec) {
const kpis = sec.kpi_short || []
if (kpis.some((k) => k.status === 'bad')) return 'bad'
if (kpis.some((k) => k.status === 'warn')) return 'warn'
const interp = sec.interpretation_short || []
if (interp.some((x) => x.status === 'bad')) return 'bad'
if (interp.some((x) => x.status === 'warn')) return 'warn'
const heur = sec.heuristic_short || []
if (heur.some((h) => h.status === 'warn')) return 'warn'
return 'good'
}
function overviewConfidenceUi(conf) {
if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' }
if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' }
return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' }
}
function chartJsScatterPoints(payload) {
const raw = payload?.data?.datasets?.[0]?.data || []
if (!Array.isArray(raw)) return []
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
}
function driverBarFromStatus(st) {
const s = String(st || '').toLowerCase()
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' }
return { v: 0.15, fill: '#6B7280' }
}
function chartJsBarRows(payload, fallbackDrivers) {
const labels = payload?.data?.labels || []
const values = payload?.data?.datasets?.[0]?.data || []
const colors = payload?.data?.datasets?.[0]?.backgroundColor
if (labels.length && values.length) {
return labels.map((name, i) => ({
name: name.length > 42 ? `${name.slice(0, 40)}…` : name,
value: Number(values[i]),
fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75',
}))
}
if (fallbackDrivers?.length) {
return fallbackDrivers.map((d) => {
const { v, fill } = driverBarFromStatus(d.status)
return {
name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}…` : String(d.factor || '—'),
value: v,
fill,
subtitle: d.reason,
}
})
}
return []
}
function CorrelationScatterTile({ title, accent, payload }) {
const meta = payload?.metadata || {}
const pts = chartJsScatterPoints(payload)
const hasChart = pts.length > 0 && meta.correlation != null
const r = Number(meta.correlation)
const strength =
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
return (
{title}
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
{meta.best_lag_days != null ? ` · Lag ${meta.best_lag_days} T` : ''}
{meta.metric ? ` · ${meta.metric}` : ''}
{meta.confidence ? ` · ${meta.confidence}` : ''}
{!hasChart ? (
{meta.message || 'Keine Daten für diese Korrelation.'}
) : (
)}
{meta.interpretation ? (
{meta.interpretation}
) : null}
)
}
function DriversImpactTile({ payload, driversFallback }) {
const meta = payload?.metadata || {}
const rows = chartJsBarRows(payload, driversFallback)
if (!rows.length) {
return (
C4 Einflussfaktoren
{meta.message || 'Keine Treiber-Daten.'}
)
}
const h = Math.min(220, Math.max(96, rows.length * 34))
return (
C4 Einflussfaktoren
{
if (!active || !pp?.length) return null
const p = pp[0].payload
return (
{p.name}
{p.subtitle ?
{p.subtitle}
: null}
)
}}
/>
{rows.map((e, i) => (
|
))}
)
}
// ── Gesamtansicht (Layer 2b: overview + Chart-Endpunkte C1–C4) ──────────────────
function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
const navigate = useNavigate()
const [period, setPeriod] = useState(30)
const [bundle, setBundle] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
const daysReq = period === 9999 ? 3650 : period
setLoading(true)
Promise.all([
api.getHistoryOverviewViz(daysReq),
api.getWeightEnergyCorrelationChart(14),
api.getLbmProteinCorrelationChart(14),
api.getLoadVitalsCorrelationChart(14),
api.getRecoveryPerformanceChart(),
])
.then(([overview, chartC1, chartC2, chartC3, chartC4]) => {
if (!cancelled) {
setBundle({ overview, chartC1, chartC2, chartC3, chartC4 })
setErr(null)
}
})
.catch((e) => {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [period])
if (loading) {
return (
)
}
if (err) {
return (
)
}
const data = bundle?.overview
const chartC1 = bundle?.chartC1
const chartC2 = bundle?.chartC2
const chartC3 = bundle?.chartC3
const chartC4 = bundle?.chartC4
const lag = data?.lag_correlations || {}
const c4drivers = lag.recovery_performance?.drivers || []
const sections = data?.sections || []
const confUi = overviewConfidenceUi(data?.confidence)
return (
{confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'}
{confUi.label}
{confUi.hint}
KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '}
Ehem. «Korrelation»-Charts (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '}
navigate('/history', { state: { tab: 'nutrition' } })}>
Ernährung
. Die Kacheln C1–C4 unten nutzen dieselben Chart-Endpunkte wie die API (/api/charts/*).
{sections.map((sec) => {
const tone = overviewSectionTone(sec)
const stripe = getStatusColor(tone)
const badgeBg = getStatusBg(tone)
return (
{tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'}
{sec.title}
navigate('/history', { state: { tab: sec.tab_id } })}
>
Öffnen
{sec.summary_line}
{(sec.kpi_short || []).length > 0 && (
{(sec.kpi_short || []).map((k, i) => (
{k.category}
{k.value}
{k.sublabel ?
{k.sublabel}
: null}
))}
)}
{(sec.interpretation_short || []).map((it, i) => (
))}
{(sec.heuristic_short || []).map((h, i) => (
))}
{(sec.insights_short || []).map((ins, i) => (
))}
)
})}
Lag-Korrelationen (C1–C3)
Einflussfaktoren (C4)
)
}
// ── 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)}
{
e.stopPropagation()
handleDelete(p.id)
}}
>
))}
))}
>
)
}
// ── Main ──────────────────────────────────────────────────────────────────────
const TABS = [
{ id:'overview', label:'📊 Gesamt' },
{ id:'body', label:'⚖️ Körper' },
{ id:'nutrition', label:'🍽️ Ernährung' },
{ id:'activity', label:'🏋️ Fitness' },
{ 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) || 'overview')
const [weights, setWeights] = useState([])
const [calipers, setCalipers] = useState([])
const [circs, setCircs] = useState([])
const [nutrition, setNutrition] = useState([])
const [activities, setActivities] = 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.latestInsights(), api.getProfile(),
api.listPrompts(),
]).then(([w,ca,ci,n,a,ins,p,pr])=>{
setWeights(w); setCalipers(ca); setCircs(ci)
setNutrition(n); setActivities(a)
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 === 'recovery') {
setTab('activity')
return
}
if (t === 'correlation') {
setTab('nutrition')
return
}
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
{TABS.map(t => (
setTab(t.id)}
aria-current={tab === t.id ? 'page' : undefined}
>
{t.label}
))}
{tab==='overview' &&
}
{tab==='body' &&
}
{tab==='nutrition' &&
}
{tab==='activity' &&
}
{tab==='photos' &&
}
)
}