mitai-jinkendo/frontend/src/pages/History.jsx
Lars 5d67a77a12
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: enhance History page with new scatter chart and correlation insights
- Added ScatterChart and related functions to visualize correlation data, improving user understanding of relationships between metrics.
- Introduced new utility functions for processing chart data and determining status tones, enhancing the clarity of visual representations.
- Updated the NutritionSection to include additional insights on calorie balance and protein vs. lean mass, providing a more comprehensive overview of nutrition trends.
2026-04-20 14:31:35 +02:00

1830 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>nav('/caliper')}>Caliper-Daten <ChevronRight size={10}/></button>
}
function NavToCircum() {
const nav = useNavigate()
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>nav('/circum')}>Umfang-Daten <ChevronRight size={10}/></button>
}
function EmptySection({ text, to, toLabel }) {
const nav = useNavigate()
return (
<div style={{padding:32,textAlign:'center',color:'var(--text3)',fontSize:13}}>
<div style={{marginBottom:12}}>{text}</div>
{to && <button className="btn btn-primary" onClick={()=>nav(to)}>{toLabel||'Daten erfassen'}</button>}
</div>
)
}
function SectionHeader({ title, to, toLabel, lastUpdated }) {
const nav = useNavigate()
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>{title}</h2>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
{lastUpdated && <span style={{fontSize:11,color:'var(--text3)'}}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
{to && (
<button className="btn btn-secondary" style={{fontSize:12,padding:'5px 10px'}} onClick={()=>nav(to)}>
{toLabel||'Daten'} <ChevronRight size={12}/>
</button>
)}
</div>
</div>
)
}
function RuleCard({ item }) {
const [open, setOpen] = useState(false)
const color = getStatusColor(item.status)
return (
<div style={{border:`1px solid ${color}33`,borderRadius:8,marginBottom:6,overflow:'hidden'}}>
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:getStatusBg(item.status)+'88',cursor:'pointer'}} onClick={()=>setOpen(o=>!o)}>
<span style={{fontSize:15}}>{item.icon}</span>
<div style={{flex:1}}>
<div style={{fontSize:11,fontWeight:600,color,textTransform:'uppercase',letterSpacing:'0.04em'}}>{item.category}</div>
<div style={{fontSize:13,fontWeight:500}}>{item.title}</div>
</div>
{item.value && <span style={{fontSize:14,fontWeight:700,color}}>{item.value}</span>}
{open ? <ChevronUp size={14} color="var(--text3)"/> : <ChevronDown size={14} color="var(--text3)"/>}
</div>
{open && <div style={{padding:'8px 12px',fontSize:12,color:'var(--text2)',lineHeight:1.6,
borderTop:`1px solid ${color}22`}}>{item.detail}</div>}
</div>
)
}
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 (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Ernährungsbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
function BodyGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
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 (
<div style={{marginTop:14}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>🤖 KI-AUSWERTUNGEN</div>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{slugs.map(slug=>(
<button key={slug} className="btn btn-secondary" style={{fontSize:11,padding:'4px 8px'}}
onClick={()=>onRequest(slug)} disabled={loading===slug}>
{loading===slug
? <div className="spinner" style={{width:11,height:11}}/>
: <><Brain size={11}/> {LABELS[slug]||slug}</>}
</button>
))}
</div>
</div>
{relevant.length===0 && (
<div style={{padding:'10px 12px',background:'var(--surface2)',borderRadius:8,
fontSize:12,color:'var(--text3)'}}>
Noch keine Auswertung. Klicke oben um eine zu erstellen.
</div>
)}
{relevant.map(ins=>(
<div key={ins.id} style={{marginBottom:8,border:'1px solid var(--accent)33',borderRadius:10,overflow:'hidden'}}>
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:'var(--accent-light)66',cursor:'pointer'}}
onClick={()=>setExpanded(expanded===ins.id?null:ins.id)}>
<div style={{flex:1,fontSize:11,color:'var(--accent)',fontWeight:600}}>
{dayjs(ins.created).format('DD. MMM YYYY, HH:mm')} · {LABELS[ins.scope]||ins.scope}
</div>
{expanded===ins.id?<ChevronUp size={14}/>:<ChevronDown size={14}/>}
</div>
{expanded===ins.id && <div style={{padding:'12px 14px'}}><Markdown text={ins.content}/></div>}
</div>
))}
</div>
)
}
// ── 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 (
<div style={{display:'flex',gap:4,marginBottom:12}}>
{opts.map(o=>(
<button key={o.v} onClick={()=>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}
</button>
))}
</div>
)
}
// ── 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 (
<div>
<SectionHeader title="⚖️ Körper" />
<div className="empty-state"><div className="spinner" /></div>
</div>
)
}
if (vizError) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
</div>
)
}
if (!hasAnyData) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<PeriodSelector value={period} onChange={setPeriod} />
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
</div>
)
}
return (
<div>
<SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />
<PeriodSelector value={period} onChange={setPeriod} />
<BodyGoalsStrip grouped={groupedGoals} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf Fitness</strong>.
</p>
{viz?.meta?.layer_2a_alignment && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
{viz.meta.layer_2a_alignment}
</div>
)}
<KpiTilesOverview tiles={kpiTiles} />
{vizLoading && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>
)}
{hasWeight && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
Gewicht · {w?.data_points || 0} Einträge
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
Daten <ChevronRight size={10} />
</button>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={wCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(wCd.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{avgAll != null && (
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
)}
{goalW != null && (
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}kg`, fontSize: 9, fill: 'var(--accent)', position: 'right' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5} dot={{ r: 3, fill: '#378ADD', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} name="weight" />
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5} dot={false} name="avg7" />
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2} dot={false} strokeDasharray="6 3" name="avg14" />
</LineChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD88', verticalAlign: 'middle', marginRight: 3 }} /> Täglich</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD', verticalAlign: 'middle', marginRight: 3 }} />Ø 7T</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" /></svg>Ø 14T</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: 'var(--text3)', verticalAlign: 'middle', marginRight: 3, borderTop: '2px dashed' }} />Ø Gesamt</span>
</div>
</div>
)}
{bfCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperfett (Caliper)</div>
<NavToCaliper />
</div>
<ResponsiveContainer width="100%" height={170}>
<LineChart data={bfCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={v => [`${v}%`, 'KF%']} />
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
</LineChart>
</ResponsiveContainer>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% zweite Kurve entfällt.</div>
</div>
)}
{propChartData.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
<strong>V-Taper (Brust Taille)</strong> in cm.
{showBellyOnProp && <><strong> Bauch</strong> (rechte Achse).</>}
</div>
</div>
<NavToCircum />
</div>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart data={propChartData} margin={{ top: 4, right: showBellyOnProp ? 4 : 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{showBellyOnProp && <YAxis yAxisId="belly" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => {
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]
}}
/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
</ComposedChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust Taille</span>
<span><span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 4" /></svg></span>gleitender Mittelwert</span>
{showBellyOnProp && <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch (cm)</span>}
</div>
</div>
)}
{idxOk && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
</div>
<NavToCircum />
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={idxSeries} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
{idxSeries.some(d => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
{idxSeries.some(d => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
{idxSeries.some(d => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
</LineChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#EF9F27', verticalAlign: 'middle', marginRight: 3 }} />Taille</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch</span>
</div>
</div>
)}
{propChartData.length < 2 && cirCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
<NavToCircum />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={cirCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} cm`, n]} />
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
{cirCd.some(d => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
</LineChart>
</ResponsiveContainer>
</div>
)}
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div>
)
}
/** 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 (
<div
className="kcal-vs-weight-legend"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: '12px 18px',
marginTop: 10,
fontSize: 10,
color: 'var(--text2)',
lineHeight: 1.35,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span style={line('#EA580C')} />
Ø Kalorien (7-Tage-Mittel)
</span>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 9,
height: 9,
borderRadius: '50%',
background: '#2563EB',
marginRight: 6,
verticalAlign: 'middle',
}}
/>
Gewicht (kg)
</span>
{showTdee ? (
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 22,
height: 0,
verticalAlign: 'middle',
marginRight: 6,
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
opacity: 0.95,
}}
/>
TDEE-Referenz (geschätzt)
</span>
) : null}
</div>
)
}
/** 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 (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomain} />
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
{tdeeLabel != null && (
<ReferenceLine
yAxisId="kcal"
y={tdeeLabel}
stroke={TDEE_REF_LINE_COLOR}
strokeDasharray="6 5"
strokeWidth={2}
isFront
/>
)}
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
</LineChart>
</ResponsiveContainer>
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
{tdeeLabel != null
? `TDEE ~${tdeeLabel} kcal · ${n} gemeinsame Tage`
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`}
</div>
</div>
)
}
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 (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomainFb} />
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
<ReferenceLine
yAxisId="kcal"
y={tdee}
stroke={TDEE_REF_LINE_COLOR}
strokeDasharray="6 5"
strokeWidth={2}
isFront
/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
</LineChart>
</ResponsiveContainer>
<KcalVsWeightLegend showTdee />
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
</div>
</div>
)
}
// ── 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 (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
<PeriodSelector value={period} onChange={setPeriod} />
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (vizErr) {
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{vizErr}</div>
</div>
)
}
if (!viz?.has_nutrition_entries) {
return (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
)
}
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 (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
</div>
)
}
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
{' '}
<strong>Kalorienbilanz</strong>, <strong>Protein vs. Magermasse</strong> und den Block{' '}
<strong>«Kurz-Einordnung»</strong> finden Sie hier früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
</p>
<NutritionGoalsStrip grouped={groupedGoals} />
<KpiTilesOverview tiles={kpiTiles} />
<KcalVsWeightChart
vizKcalWeight={viz.kcal_vs_weight}
corrData={[]}
profile={profile}
cutoffDate=""
allTime={period === 9999}
/>
{balDaily.length > 0 && tdeeRef != null && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorienbilanz (Aufnahme TDEE ~{Math.round(tdeeRef)} kcal)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Tagesbilanz und 7-Tage-Mittel gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart
data={balDaily.map((d) => ({ ...d, date: fmtDate(d.date) }))}
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(balDaily.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
/>
<Line type="monotone" dataKey="balance_kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} name="balance_kcal" />
<Line type="monotone" dataKey="balance_kcal_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_kcal_avg" />
</LineChart>
</ResponsiveContainer>
</div>
)}
{plmPts.length >= 3 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Protein vs. Magermasse (Caliper, forward-filled)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={plmPts.map((d) => ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="prot" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<YAxis yAxisId="lean" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{plm.protein_target_low_g > 0 && (
<ReferenceLine
yAxisId="prot"
y={plm.protein_target_low_g}
stroke="#1D9E75"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: `${plm.protein_target_low_g}g`, fontSize: 9, fill: '#1D9E75', position: 'right' }}
/>
)}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']}
/>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein" />
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3, fill: '#7F77DD' }} name="lean" />
</LineChart>
</ResponsiveContainer>
</div>
)}
{nutHeur.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Ernährung Kurz-Einordnung</div>
{nutHeur.map((item, i) => (
<div
key={i}
style={{
padding: '10px 12px',
borderRadius: 8,
marginBottom: 6,
background: item.status === 'good' ? 'var(--accent-light)' : 'var(--warn-bg)',
border: `1px solid ${item.status === 'good' ? 'var(--accent)' : 'var(--warn)'}33`,
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{ fontSize: 16 }}>{item.icon || '•'}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 3, lineHeight: 1.5 }}>{item.detail}</div>
</div>
</div>
</div>
))}
</div>
)}
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Makroverteilung täglich (g) · Fokus Protein
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(cdMacro.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
{ptLow > 0 && (
<ReferenceLine y={ptLow} stroke={MACRO_CHART.protein} strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: MACRO_CHART.protein, position: 'insideTopRight' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein} name="Protein" />
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat} name="Fett" />
<Bar dataKey="KH" stackId="a" fill={MACRO_CHART.carbs} name="KH" radius={[5, 5, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.protein, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (unten)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.fat, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett (Mitte)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.carbs, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />KH (oben)</span>
</div>
</div>
<div className="nutrition-macro-pair">
<div className="card nutrition-macro-pair__donut">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Ø Makro-Quote ({n} Tage)
</div>
{pieData.length > 0 ? (
<div className="nutrition-macro-pair__donut-inner">
<div className="nutrition-macro-pair__donut-chart">
<ResponsiveContainer width="100%" height={NUTRITION_MACRO_CHART_BLOCK_PX}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius="38%"
outerRadius="58%"
dataKey="value"
startAngle={90}
endAngle={-270}
paddingAngle={1}
>
{pieData.map((e, i) => (
<Cell key={i} fill={macroFillByName(e.name)} />
))}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="nutrition-macro-pair__legend">
{pieData.map(p => {
const fill = macroFillByName(p.name)
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: fill, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: fill }}>{p.value}%</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{p.grams != null ? `${p.grams}g` : '—'}
</div>
</div>
)
})}
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
</div>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
)}
</div>
<div className="card nutrition-macro-pair__weekly">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
Wöchentliche Makro-Verteilung (Backend)
</div>
<WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={wmLoading} error={wmError} />
</div>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
Zeitverläufe (Energie & Protein)
</div>
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} hideEnergyAvailabilityCard />
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// ── 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 (
<div>
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={actList[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.
</p>
<FitnessDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 20 }}>
Erholung (Schlaf, HRV, Vitalwerte)
</div>
<RecoveryDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
{hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
<div style={{
marginTop: 12,
marginBottom: 12, padding:'8px 12px', borderRadius:8,
background:'var(--surface2)', border:'1px solid var(--border)',
fontSize:12, color:'var(--text2)', display:'flex', alignItems:'center', gap:8
}}>
<span>
{globalQualityLevel === 'quality' && '✓ Filter: Hochwertig (excellent, good, acceptable)'}
{globalQualityLevel === 'very_good' && '✓✓ Filter: Sehr gut (excellent, good)'}
{globalQualityLevel === 'excellent' && '⭐ Filter: Exzellent (nur excellent)'}
</span>
<a href="/settings" style={{
marginLeft:'auto', color:'var(--accent)', textDecoration:'none',
fontSize:11, fontWeight:500, whiteSpace:'nowrap'
}}>
Hier ändern
</a>
</div>
)}
<InsightBox
insights={insights}
slugs={filterActiveSlugs(['aktivitaet', 'gesundheit'])}
onRequest={onRequest}
loading={loadingSlug}
/>
</div>
)
}
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 (
<div
className="card"
style={{
marginBottom: 0,
padding: 10,
borderLeft: `4px solid ${getStatusColor(strength)}`,
}}
>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
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}` : ''}
</div>
{!hasChart ? (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Daten für diese Korrelation.'}</div>
) : (
<ResponsiveContainer width="100%" height={118}>
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" dataKey="x" domain={[0, 28]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<YAxis type="number" dataKey="y" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }} />
<Scatter name="r" data={pts} fill={accent} />
</ScatterChart>
</ResponsiveContainer>
)}
{meta.interpretation ? (
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 6, lineHeight: 1.4 }}>{meta.interpretation}</div>
) : null}
</div>
)
}
function DriversImpactTile({ payload, driversFallback }) {
const meta = payload?.metadata || {}
const rows = chartJsBarRows(payload, driversFallback)
if (!rows.length) {
return (
<div className="card" style={{ padding: 12, borderLeft: '4px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Treiber-Daten.'}</div>
</div>
)
}
const h = Math.min(220, Math.max(96, rows.length * 34))
return (
<div className="card" style={{ padding: 10, borderLeft: '4px solid var(--accent)' }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
<ResponsiveContainer width="100%" height={h}>
<BarChart data={rows} layout="vertical" margin={{ left: 2, right: 6, top: 2, bottom: 2 }}>
<XAxis type="number" domain={[-1.2, 1.2]} tick={{ fontSize: 9 }} />
<YAxis type="category" dataKey="name" width={112} tick={{ fontSize: 9, fill: 'var(--text2)' }} />
<Tooltip
content={({ active, payload: pp }) => {
if (!active || !pp?.length) return null
const p = pp[0].payload
return (
<div
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
padding: '8px 10px',
borderRadius: 8,
fontSize: 11,
maxWidth: 280,
}}
>
<div style={{ fontWeight: 600 }}>{p.name}</div>
{p.subtitle ? <div style={{ marginTop: 4, color: 'var(--text2)', lineHeight: 1.4 }}>{p.subtitle}</div> : null}
</div>
)
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{rows.map((e, i) => (
<Cell key={i} fill={e.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── Gesamtansicht (Layer 2b: overview + Chart-Endpunkte C1C4) ──────────────────
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 (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<PeriodSelector value={period} onChange={setPeriod} />
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (err) {
return (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>
</div>
)
}
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 (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<PeriodSelector value={period} onChange={setPeriod} />
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 10,
marginBottom: 14,
padding: '10px 12px',
borderRadius: 12,
border: '1px solid var(--border)',
background: getStatusBg(confUi.tone),
borderLeft: `5px solid ${getStatusColor(confUi.tone)}`,
}}
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'}</span>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{confUi.label}</div>
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 2 }}>{confUi.hint}</div>
</div>
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '}
<strong>Ehem. «Korrelation»-Charts</strong> (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '}
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => navigate('/history', { state: { tab: 'nutrition' } })}>
Ernährung
</button>
. Die Kacheln C1C4 unten nutzen dieselben Chart-Endpunkte wie die API (<code style={{ fontSize: 10 }}>/api/charts/*</code>).
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
{sections.map((sec) => {
const tone = overviewSectionTone(sec)
const stripe = getStatusColor(tone)
const badgeBg = getStatusBg(tone)
return (
<div
key={sec.id}
style={{
borderRadius: 12,
border: '1px solid var(--border)',
borderLeft: `5px solid ${stripe}`,
background: 'var(--surface)',
padding: '12px 12px 12px 14px',
boxShadow: tone === 'bad' ? '0 0 0 1px rgba(216,90,48,0.12)' : undefined,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 30,
height: 30,
borderRadius: 10,
fontSize: 13,
fontWeight: 800,
color: stripe,
background: badgeBg,
}}
>
{tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'}
</span>
<div style={{ fontSize: 15, fontWeight: 700 }}>{sec.title}</div>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
>
Öffnen
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.45 }}>{sec.summary_line}</div>
{(sec.kpi_short || []).length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(128px, 1fr))', gap: 8, marginBottom: 8 }}>
{(sec.kpi_short || []).map((k, i) => (
<div
key={i}
style={{
padding: '8px 10px',
borderRadius: 10,
background: getStatusBg(k.status || 'good'),
border: `1px solid ${getStatusColor(k.status || 'good')}55`,
}}
>
<div style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.category}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: getStatusColor(k.status || 'good'), marginTop: 2 }}>{k.value}</div>
{k.sublabel ? <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>{k.sublabel}</div> : null}
</div>
))}
</div>
)}
{(sec.interpretation_short || []).map((it, i) => (
<div key={`in-${i}`} style={{ fontSize: 11, marginBottom: 6, paddingLeft: 8, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
<strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
<div style={{ color: 'var(--text2)', marginTop: 2, lineHeight: 1.4 }}>{it.detail}</div>
</div>
))}
{(sec.heuristic_short || []).map((h, i) => (
<div key={`he-${i}`} style={{ fontSize: 11, marginTop: 6, padding: '6px 8px', borderRadius: 8, background: 'var(--surface2)' }}>
<strong style={{ color: h.status === 'warn' ? 'var(--warn)' : 'var(--accent)' }}>{h.title}</strong>
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 2 }}>{h.detail}</div>
</div>
))}
{(sec.insights_short || []).map((ins, i) => (
<div key={`is-${i}`} style={{ fontSize: 11, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
<strong>{ins.title}</strong>
<div>{ins.body}</div>
</div>
))}
</div>
)
})}
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Lag-Korrelationen (C1C3)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 }}>
<CorrelationScatterTile title="C1 Energiebilanz ↔ Gewicht" accent="#1D9E75" payload={chartC1} />
<CorrelationScatterTile title="C2 Protein ↔ Magermasse" accent="#3B82F6" payload={chartC2} />
<CorrelationScatterTile title="C3 Last ↔ HRV/RHR" accent="#F59E0B" payload={chartC3} />
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Einflussfaktoren (C4)</div>
<DriversImpactTile payload={chartC4} driversFallback={c4drivers} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div>
)
}
// ── 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 <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
}
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 && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.9)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setBig(null)}
role="presentation"
>
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
</div>
)}
{monthKeys.map((mk) => (
<div key={mk} style={{ marginBottom: 16 }}>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: 'var(--text3)',
marginBottom: 10,
textTransform: 'capitalize',
}}
>
{dayjs(`${mk}-01`).format('MMMM YYYY')}
</div>
<div className="photo-grid">
{byMonth.get(mk).map((p) => (
<div key={p.id} style={{ position: 'relative' }}>
<img
src={api.photoUrl(p.id)}
className="photo-thumb"
onClick={() => setBig(p.id)}
alt=""
/>
<div
style={{
position: 'absolute',
bottom: 4,
left: 4,
fontSize: 9,
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '1px 4px',
borderRadius: 3,
}}
>
{formatPhotoCaption(p)}
</div>
<button
type="button"
className="btn btn-secondary"
style={{
position: 'absolute',
top: 4,
right: 4,
padding: 6,
minWidth: 0,
borderRadius: 8,
}}
title="Löschen"
onClick={(e) => {
e.stopPropagation()
handleDelete(p.id)
}}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
))}
</>
)
}
// ── 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 <div className="empty-state"><div className="spinner"/></div>
// 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 (
<div className="history-page">
<h1 className="page-title history-page__title">Verlauf & Auswertung</h1>
<div className="history-page__layout">
<nav className="history-tabs" aria-label="Verlauf-Kategorien">
<div className="history-tabs__scroller">
{TABS.map(t => (
<button
key={t.id}
type="button"
className={`history-tab-btn${tab === t.id ? ' history-tab-btn--active' : ''}`}
onClick={() => setTab(t.id)}
aria-current={tab === t.id ? 'page' : undefined}
>
{t.label}
</button>
))}
</div>
</nav>
<div className="history-content">
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
{tab==='body' && <BodySection profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='photos' && <PhotoGrid/>}
</div>
</div>
</div>
)
}