- Refactored the `calculate_proxy_internal_load_7d` function to `calculate_proxy_internal_load_window`, allowing for dynamic day range input. - Introduced new functions for calculating training volume deltas and building fitness progress insights, enhancing user feedback on training metrics. - Updated the fitness dashboard to include new charts for quality sessions and load monitoring, improving data visualization. - Integrated these new metrics into the fitness dashboard overview, providing users with comprehensive insights into their training performance. - Streamlined the router to utilize the new chart-building functions, ensuring consistency and maintainability across the application.
1555 lines
70 KiB
JavaScript
1555 lines
70 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate, useLocation } from 'react-router-dom'
|
||
import { useProfile } from '../context/ProfileContext'
|
||
import {
|
||
LineChart, Line, BarChart, Bar,
|
||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||
ReferenceLine, PieChart, Pie, Cell, ComposedChart
|
||
} from 'recharts'
|
||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||
import { api } from '../utils/api'
|
||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||
import { getBfCategory } from '../utils/calc'
|
||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
||
import Markdown from '../utils/Markdown'
|
||
import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
|
||
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
||
import RecoveryCharts from '../components/RecoveryCharts'
|
||
import KpiTilesOverview from '../components/KpiTilesOverview'
|
||
import dayjs from 'dayjs'
|
||
import 'dayjs/locale/de'
|
||
dayjs.locale('de')
|
||
|
||
function rollingAvg(arr, key, window=7) {
|
||
return arr.map((d,i) => {
|
||
const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
|
||
return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d
|
||
})
|
||
}
|
||
const fmtDate = d => dayjs(d).format('DD.MM')
|
||
|
||
function NavToCaliper() {
|
||
const nav = useNavigate()
|
||
return <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
|
||
|
||
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 Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
|
||
</p>
|
||
|
||
<NutritionGoalsStrip grouped={groupedGoals} />
|
||
|
||
<KpiTilesOverview tiles={kpiTiles} />
|
||
|
||
<KcalVsWeightChart
|
||
vizKcalWeight={viz.kcal_vs_weight}
|
||
corrData={[]}
|
||
profile={profile}
|
||
cutoffDate=""
|
||
allTime={period === 9999}
|
||
/>
|
||
|
||
<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 }}>
|
||
Auswertung ausschließlich aus dem Fitness-Bundle (Data-Layer / Issue 53). Zeitraum-Buttons steuern dasselbe
|
||
Fenster wie die API.
|
||
</p>
|
||
<FitnessDashboardOverview 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>
|
||
)}
|
||
|
||
{hasList ? (
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Correlation Section ───────────────────────────────────────────────────────
|
||
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
|
||
if (filtered.length < 5) return (
|
||
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
|
||
)
|
||
|
||
const sex = profile?.sex||'m'
|
||
const height = profile?.height||178
|
||
const latestW = filtered[filtered.length-1]?.weight||80
|
||
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35
|
||
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161
|
||
const tdee = Math.round(bmr*1.4) // light activity baseline
|
||
|
||
// Protein vs Lean Mass (only days with both)
|
||
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
|
||
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass}))
|
||
|
||
// Chart 3: Activity kcal vs Weight change
|
||
const actVsW = filtered.filter(d=>d.weight)
|
||
.map((d,i,arr)=>{
|
||
const prev = arr[i-1]
|
||
return {
|
||
date: fmtDate(d.date),
|
||
weight: d.weight,
|
||
weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null,
|
||
kcal: d.kcal||0,
|
||
}
|
||
}).filter(d=>d.weightDelta!==null)
|
||
|
||
// Chart 4: Calorie balance (intake - estimated TDEE)
|
||
const balance = filtered.map(d=>({
|
||
date: fmtDate(d.date),
|
||
balance: Math.round((d.kcal||0) - tdee),
|
||
}))
|
||
const balWithAvg = rollingAvg(balance,'balance')
|
||
const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length)
|
||
|
||
// ── Correlation insights ──
|
||
const corrInsights = []
|
||
|
||
// 1. Kcal → Weight correlation
|
||
if (filtered.length >= 14) {
|
||
const highKcal = filtered.filter(d=>d.kcal>tdee+200)
|
||
const lowKcal = filtered.filter(d=>d.kcal<tdee-200)
|
||
if (highKcal.length>=3 && lowKcal.length>=3) {
|
||
const avgWHigh = Math.round(highKcal.reduce((s,d)=>s+d.weight,0)/highKcal.length*10)/10
|
||
const avgWLow = Math.round(lowKcal.reduce((s,d)=>s+d.weight,0)/lowKcal.length*10)/10
|
||
corrInsights.push({
|
||
icon:'📊', status: avgWLow < avgWHigh ? 'good' : 'warn',
|
||
title: avgWLow < avgWHigh
|
||
? `Kalorienreduktion wirkt: Ø ${avgWLow}kg bei Defizit vs. ${avgWHigh}kg bei Überschuss`
|
||
: `Kein klarer Kalorieneffekt auf Gewicht erkennbar`,
|
||
detail: `Tage mit Überschuss (>${tdee+200} kcal): Ø ${avgWHigh}kg · Tage mit Defizit (<${tdee-200} kcal): Ø ${avgWLow}kg`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 2. Protein → Lean mass
|
||
if (protVsLean.length >= 3) {
|
||
const ptLow = Math.round(latestW*1.6)
|
||
const highProt = protVsLean.filter(d=>d.protein>=ptLow)
|
||
const lowProt = protVsLean.filter(d=>d.protein<ptLow)
|
||
if (highProt.length>=2 && lowProt.length>=2) {
|
||
const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10
|
||
const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10
|
||
corrInsights.push({
|
||
icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn',
|
||
title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`,
|
||
detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 3. Avg balance
|
||
corrInsights.push({
|
||
icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️',
|
||
status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good',
|
||
title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`,
|
||
detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${
|
||
avgBalance<-500?'Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen.':
|
||
avgBalance<-100?'Moderates Defizit – ideal für Fettabbau bei Muskelerhalt.':
|
||
avgBalance>300?'Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich.':
|
||
'Nahezu ausgeglichen – Gewicht sollte stabil bleiben.'}`,
|
||
})
|
||
|
||
return (
|
||
<div>
|
||
<SectionHeader title="🔗 Korrelationen"/>
|
||
|
||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
|
||
Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf → Ernährung</strong> (gleiche Datenbasis).
|
||
</p>
|
||
|
||
{/* Chart: Calorie balance */}
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<LineChart data={balWithAvg} 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(balWithAvg.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_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
|
||
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
|
||
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
||
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chart 3: Protein vs Lean Mass */}
|
||
{protVsLean.length >= 3 && (
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||
🥩 Protein vs. Magermasse
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<LineChart data={protVsLean} 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']}/>
|
||
<ReferenceLine yAxisId="prot" y={Math.round(latestW*1.6)} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
|
||
label={{value:`Ziel ${Math.round(latestW*1.6)}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:4,fill:'#7F77DD'}} name="lean"/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
||
<span style={{color:'#1D9E75'}}>— Protein g/Tag</span> · <span style={{color:'#7F77DD'}}>● Magermasse kg</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Correlation insights */}
|
||
{corrInsights.length > 0 && (
|
||
<div style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>KORRELATIONSAUSSAGEN</div>
|
||
{corrInsights.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 style={{fontSize:11,color:'var(--text3)',padding:'6px 10px',background:'var(--surface2)',
|
||
borderRadius:6,marginTop:6}}>
|
||
ℹ️ TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<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 ──────────────────────────────────────────────────────────────────────
|
||
// ── Recovery Section ──────────────────────────────────────────────────────────
|
||
function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
const [period, setPeriod] = useState(28)
|
||
|
||
return (
|
||
<div>
|
||
<SectionHeader title="😴 Erholung & Vitalwerte" to="/vitals" toLabel="Daten"/>
|
||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||
|
||
<div style={{marginBottom:12,fontSize:13,color:'var(--text2)',lineHeight:1.6}}>
|
||
Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick.
|
||
</div>
|
||
|
||
{/* Recovery Charts (Phase 0c) */}
|
||
<RecoveryCharts days={period === 9999 ? 90 : period} />
|
||
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesundheit'])} onRequest={onRequest} loading={loadingSlug}/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const TABS = [
|
||
{ id:'body', label:'⚖️ Körper' },
|
||
{ id:'nutrition', label:'🍽️ Ernährung' },
|
||
{ id:'activity', label:'🏋️ Fitness' },
|
||
{ id:'recovery', label:'😴 Erholung' },
|
||
{ id:'correlation', label:'🔗 Korrelation' },
|
||
{ id:'photos', label:'📷 Fotos' },
|
||
]
|
||
|
||
export default function History() {
|
||
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
|
||
const location = useLocation?.() || {}
|
||
const [tab, setTab] = useState((location.state?.tab)||'body')
|
||
const [weights, setWeights] = useState([])
|
||
const [calipers, setCalipers] = useState([])
|
||
const [circs, setCircs] = useState([])
|
||
const [nutrition, setNutrition] = useState([])
|
||
const [activities, setActivities] = useState([])
|
||
const [corrData, setCorrData] = useState([])
|
||
const [insights, setInsights] = useState([])
|
||
const [prompts, setPrompts] = useState([])
|
||
const [profile, setProfile] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [loadingSlug,setLoadingSlug]= useState(null)
|
||
|
||
const loadAll = () => Promise.all([
|
||
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
||
api.listNutrition(90), api.listActivity(25_000),
|
||
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
||
api.listPrompts(),
|
||
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
||
setWeights(w); setCalipers(ca); setCircs(ci)
|
||
setNutrition(n); setActivities(a); setCorrData(corr)
|
||
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
||
setPrompts(Array.isArray(pr)?pr:[])
|
||
setLoading(false)
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadAll()
|
||
}, [activeProfile?.quality_filter_level])
|
||
|
||
useEffect(() => {
|
||
const t = location.state?.tab
|
||
if (t && TABS.some(x => x.id === t)) setTab(t)
|
||
}, [location.state?.tab])
|
||
|
||
const requestInsight = async (slug) => {
|
||
setLoadingSlug(slug)
|
||
try {
|
||
const result = await api.runInsight(slug)
|
||
// result is already JSON, not a Response object
|
||
const ins = await api.latestInsights()
|
||
setInsights(Array.isArray(ins)?ins:[])
|
||
} catch(e){
|
||
alert('KI-Fehler: '+e.message)
|
||
}
|
||
finally{ setLoadingSlug(null) }
|
||
}
|
||
|
||
if(loading) return <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==='body' && <BodySection profile={profile} {...sp}/>}
|
||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||
{tab==='recovery' && <RecoverySection {...sp}/>}
|
||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||
{tab==='photos' && <PhotoGrid/>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|