- 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.
1830 lines
81 KiB
JavaScript
1830 lines
81 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,
|
||
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 Mifflin–St 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 C1–C4) ──────────────────
|
||
function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
const navigate = useNavigate()
|
||
const [period, setPeriod] = useState(30)
|
||
const [bundle, setBundle] = useState(null)
|
||
const [err, setErr] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
const daysReq = period === 9999 ? 3650 : period
|
||
setLoading(true)
|
||
Promise.all([
|
||
api.getHistoryOverviewViz(daysReq),
|
||
api.getWeightEnergyCorrelationChart(14),
|
||
api.getLbmProteinCorrelationChart(14),
|
||
api.getLoadVitalsCorrelationChart(14),
|
||
api.getRecoveryPerformanceChart(),
|
||
])
|
||
.then(([overview, chartC1, chartC2, chartC3, chartC4]) => {
|
||
if (!cancelled) {
|
||
setBundle({ overview, chartC1, chartC2, chartC3, chartC4 })
|
||
setErr(null)
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false)
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [period])
|
||
|
||
if (loading) {
|
||
return (
|
||
<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 C1–C4 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 (C1–C3)</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>
|
||
)
|
||
}
|