UX improvements based on user feedback: 1. Move TrainingTypeDistribution from ActivityPage to History page - ActivityPage is for data entry, not visualization - History (Verlauf) shows personal development/progress - Chart now respects period selector (7/30/90/365 days) 2. Improve AdminTrainingTypesPage form styling - All input fields now full width (100%) - Labels changed from inline to headings above fields - Textareas increased from 2 to 4 rows - Added resize: vertical for textareas - Increased gap between fields from 12px to 16px - Follows style guide conventions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
976 lines
52 KiB
JavaScript
976 lines
52 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate, useLocation } from 'react-router-dom'
|
||
import {
|
||
LineChart, Line, BarChart, Bar,
|
||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||
ReferenceLine, PieChart, Pie, Cell
|
||
} from 'recharts'
|
||
import { ChevronRight, Brain, ChevronDown, ChevronUp } from 'lucide-react'
|
||
import { api } from '../utils/api'
|
||
import { getBfCategory } from '../utils/calc'
|
||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||
import Markdown from '../utils/Markdown'
|
||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||
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 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:'Aktivität',gesundheit:'Gesundheit',ziele:'Ziele',
|
||
pipeline:'🔬 Mehrstufige Analyse',
|
||
pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung',
|
||
pipeline_activity:'Pipeline Aktivität',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 (Weight + Composition combined) ──────────────────────────────
|
||
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
const [period, setPeriod] = useState(90)
|
||
const sex = profile?.sex||'m'
|
||
const height = profile?.height||178
|
||
|
||
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
||
const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date))
|
||
.filter(d=>period===9999||d.date>=cutoff)
|
||
const filtCal = (calipers||[]).filter(d=>period===9999||d.date>=cutoff)
|
||
const filtCir = (circs||[]).filter(d=>period===9999||d.date>=cutoff)
|
||
|
||
const hasWeight = filtW.length >= 2
|
||
const hasCal = filtCal.length >= 1
|
||
const hasCir = filtCir.length >= 1
|
||
|
||
if (!hasWeight && !hasCal && !hasCir) 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>
|
||
)
|
||
|
||
// ── Weight chart ──
|
||
const withAvg = rollingAvg(filtW,'weight')
|
||
const withAvg14= rollingAvg(filtW,'weight',14)
|
||
const wCd = withAvg.map((d,i)=>({
|
||
date:fmtDate(d.date),
|
||
weight:d.weight,
|
||
avg7: d.weight_avg,
|
||
avg14: withAvg14[i]?.weight_avg,
|
||
}))
|
||
const ws = filtW.map(w=>w.weight)
|
||
const minW = ws.length ? Math.min(...ws) : null
|
||
const maxW = ws.length ? Math.max(...ws) : null
|
||
const avgAll = ws.length ? Math.round(ws.reduce((a,b)=>a+b)/ws.length*10)/10 : null
|
||
|
||
const trendPeriods = [7,30,90].map(days=>{
|
||
const cut = dayjs().subtract(days,'day').format('YYYY-MM-DD')
|
||
const per = filtW.filter(d=>d.date>=cut)
|
||
if (per.length<2) return null
|
||
const diff = Math.round((per[per.length-1].weight-per[0].weight)*10)/10
|
||
return {label:`${days}T`,diff,count:per.length}
|
||
}).filter(Boolean)
|
||
|
||
// ── Caliper chart ──
|
||
const bfCd = [...filtCal].filter(c=>c.body_fat_pct).reverse().map(c=>({
|
||
date:fmtDate(c.date),bf:c.body_fat_pct,lean:c.lean_mass,fat:c.fat_mass
|
||
}))
|
||
const latestCal = filtCal[0]
|
||
const prevCal = filtCal[1]
|
||
const latestCir = filtCir[0]
|
||
const latestW2 = filtW[filtW.length-1]
|
||
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
|
||
|
||
// ── Circ chart ──
|
||
const cirCd = [...filtCir].filter(c=>c.c_waist||c.c_hip).reverse().map(c=>({
|
||
date:fmtDate(c.date),waist:c.c_waist,hip:c.c_hip,belly:c.c_belly
|
||
}))
|
||
|
||
// ── Indicators ──
|
||
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
|
||
const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
|
||
|
||
// ── Rules ──
|
||
const combined = {
|
||
...(latestCal||{}),
|
||
c_waist:latestCir?.c_waist, c_hip:latestCir?.c_hip,
|
||
weight:latestW2?.weight
|
||
}
|
||
const rules = getInterpretation(combined, profile, prevCal||null)
|
||
|
||
return (
|
||
<div>
|
||
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
|
||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||
|
||
{/* Summary stats */}
|
||
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
||
{latestW2 && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||
<div style={{fontSize:16,fontWeight:700}}>{latestW2.weight} kg</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>Aktuell</div>
|
||
</div>}
|
||
{latestCal?.body_fat_pct && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||
<div style={{fontSize:16,fontWeight:700,color:bfCat?.color}}>{latestCal.body_fat_pct}%</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>KF {bfCat?.label}</div>
|
||
</div>}
|
||
{latestCal?.lean_mass && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||
<div style={{fontSize:16,fontWeight:700,color:'#1D9E75'}}>{latestCal.lean_mass} kg</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>Mager</div>
|
||
</div>}
|
||
{whr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
|
||
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
|
||
<div style={{fontSize:16,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>WHR</div>
|
||
</div>}
|
||
{whtr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
|
||
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
|
||
<div style={{fontSize:16,fontWeight:700,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>{whtr}</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>WHtR</div>
|
||
</div>}
|
||
</div>
|
||
|
||
{/* Weight chart – 3 lines like WeightScreen */}
|
||
{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 · {filtW.length} Einträge
|
||
</div>
|
||
<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 && <ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1}
|
||
label={{value:`Ø ${avgAll}`,fontSize:9,fill:'var(--text3)',position:'right'}}/>}
|
||
{profile?.goal_weight && <ReferenceLine y={profile.goal_weight} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5}
|
||
label={{value:`Ziel ${profile.goal_weight}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>
|
||
|
||
{/* Trend tiles */}
|
||
{trendPeriods.length>0 && (
|
||
<div style={{display:'flex',gap:6,marginTop:10}}>
|
||
{trendPeriods.map(({label,diff})=>(
|
||
<div key={label} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center',
|
||
borderTop:`3px solid ${diff<0?'var(--accent)':diff>0?'var(--warn)':'var(--border)'}`}}>
|
||
<div style={{fontSize:15,fontWeight:700,color:diff<0?'var(--accent)':diff>0?'var(--warn)':'var(--text3)'}}>
|
||
{diff>0?'+':''}{diff} kg
|
||
</div>
|
||
<div style={{fontSize:10,color:'var(--text3)'}}>{label}</div>
|
||
</div>
|
||
))}
|
||
{minW && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center'}}>
|
||
<div style={{fontSize:12,color:'var(--accent)',fontWeight:600}}>{minW}</div>
|
||
<div style={{fontSize:12,color:'var(--warn)',fontWeight:600}}>{maxW}</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>Min/Max</div>
|
||
</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* KF + Magermasse chart */}
|
||
{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)'}}>KF% + Magermasse</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 yAxisId="bf" 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']}/>
|
||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||
formatter={(v,n)=>[`${v}${n==='bf'?'%':' kg'}`,n==='bf'?'KF%':'Mager']}/>
|
||
{profile?.goal_bf_pct && <ReferenceLine yAxisId="bf" y={profile.goal_bf_pct}
|
||
stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5}
|
||
label={{value:`Ziel ${profile.goal_bf_pct}%`,fontSize:9,fill:'#D85A30',position:'right'}}/>}
|
||
<Line yAxisId="bf" type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{r:4,fill:'#D85A30'}} name="bf"/>
|
||
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#1D9E75" strokeWidth={2} dot={{r:3,fill:'#1D9E75'}} name="lean"/>
|
||
</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:'#D85A30',verticalAlign:'middle',marginRight:3}}/>KF%</span>
|
||
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Mager kg</span>
|
||
{profile?.goal_bf_pct && <span><span style={{display:'inline-block',width:14,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #D85A30'}}/>Ziel KF</span>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Circ trend */}
|
||
{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 Verlauf</div>
|
||
<NavToCircum/>
|
||
</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>
|
||
)}
|
||
|
||
{/* WHR / WHtR detail */}
|
||
{(whr||whtr) && (
|
||
<div style={{display:'flex',gap:8,marginBottom:12}}>
|
||
{whr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
|
||
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
|
||
<div style={{fontSize:20,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
|
||
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHR</div>
|
||
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Hüfte</div>
|
||
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel <{sex==='m'?'0,90':'0,85'}</div>
|
||
<div style={{fontSize:10,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>
|
||
{whr<(sex==='m'?0.90:0.85)?'✓ Günstig':'⚠️ Erhöht'}</div>
|
||
</div>}
|
||
{whtr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
|
||
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
|
||
<div style={{fontSize:20,fontWeight:700,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>{whtr}</div>
|
||
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHtR</div>
|
||
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Körpergröße</div>
|
||
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel <0,50</div>
|
||
<div style={{fontSize:10,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>
|
||
{whtr<0.5?'✓ Optimal':'⚠️ Erhöht'}</div>
|
||
</div>}
|
||
</div>
|
||
)}
|
||
|
||
{rules.length>0 && (
|
||
<div style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||
{rules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||
</div>
|
||
)}
|
||
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline','koerper','gesundheit','ziele'])}
|
||
onRequest={onRequest} loading={loadingSlug}/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
||
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
const [period, setPeriod] = useState(30)
|
||
if (!nutrition?.length) return (
|
||
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
|
||
)
|
||
|
||
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
||
const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff)
|
||
const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date))
|
||
|
||
if (!filtN.length) return (
|
||
<div>
|
||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
|
||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
|
||
</div>
|
||
)
|
||
|
||
const n = filtN.length
|
||
const avgKcal = Math.round(filtN.reduce((s,d)=>s+(d.kcal||0),0)/n)
|
||
const avgProtein = Math.round(filtN.reduce((s,d)=>s+(d.protein_g||0),0)/n*10)/10
|
||
const avgFat = Math.round(filtN.reduce((s,d)=>s+(d.fat_g||0),0)/n*10)/10
|
||
const avgCarbs = Math.round(filtN.reduce((s,d)=>s+(d.carbs_g||0),0)/n*10)/10
|
||
const latestW = weights?.[0]?.weight||80
|
||
const ptLow = Math.round(latestW*1.6)
|
||
const ptHigh = Math.round(latestW*2.2)
|
||
const proteinOk = avgProtein>=ptLow
|
||
|
||
// Stacked macro bar (daily)
|
||
const cdMacro = sorted.map(d=>({
|
||
date: fmtDate(d.date),
|
||
Protein: Math.round(d.protein_g||0),
|
||
KH: Math.round(d.carbs_g||0),
|
||
Fett: Math.round(d.fat_g||0),
|
||
kcal: Math.round(d.kcal||0),
|
||
}))
|
||
|
||
// Pie
|
||
const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9
|
||
const pieData = [
|
||
{name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'},
|
||
{name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'},
|
||
{name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'},
|
||
]
|
||
|
||
// Weekly macro bars
|
||
const weeklyMap={}
|
||
filtN.forEach(d=>{
|
||
const wk=dayjs(d.date).format('YYYY-WW')
|
||
const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })()
|
||
if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0}
|
||
weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0
|
||
weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++
|
||
})
|
||
const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({
|
||
label:w.label,
|
||
Protein:Math.round(w.protein/w.n),
|
||
KH:Math.round(w.carbs/w.n),
|
||
Fett:Math.round(w.fat/w.n),
|
||
kcal:Math.round(w.kcal/w.n),
|
||
}))
|
||
|
||
// Rules
|
||
const macroRules=[]
|
||
if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein',
|
||
title:`Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`,
|
||
detail:`1,6–2,2g/kg KG. Fehlend: ~${ptLow-Math.round(avgProtein)}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
|
||
value:avgProtein+'g'})
|
||
else macroRules.push({status:'good',icon:'🥩',category:'Protein',
|
||
title:`Gut: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`,
|
||
detail:`Ausreichend für Muskelerhalt und -aufbau.`,value:avgProtein+'g'})
|
||
const protPct=Math.round(avgProtein*4/totalMacroKcal*100)
|
||
if(protPct<20) macroRules.push({status:'warn',icon:'📊',category:'Makro-Anteil',
|
||
title:`Protein-Anteil niedrig: ${protPct}% der Kalorien`,
|
||
detail:`Empfehlung: 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`,
|
||
value:protPct+'%'})
|
||
|
||
return (
|
||
<div>
|
||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
|
||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||
|
||
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
||
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'],
|
||
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'],
|
||
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>(
|
||
<div key={l} style={{flex:1,minWidth:60,background:'var(--surface2)',borderRadius:8,
|
||
padding:'8px 6px',textAlign:'center'}}>
|
||
<div style={{fontSize:13,fontWeight:700,color:c}}>{v}</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Stacked macro bars (daily) */}
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} – {sorted[sorted.length-1]?.date?.slice(0,7)}
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={170}>
|
||
<BarChart data={cdMacro} 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(cdMacro.length/6)-1)}/>
|
||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
|
||
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/>
|
||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
|
||
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
|
||
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
|
||
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
|
||
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[2,2,0,0]}/>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
|
||
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E7599',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Protein</span>
|
||
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>KH</span>
|
||
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Fett</span>
|
||
<span><span style={{display:'inline-block',width:14,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #1D9E75'}}/>Protein-Ziel</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pie + macro breakdown */}
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:10}}>
|
||
Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} – {sorted[sorted.length-1]?.date?.slice(0,10)})
|
||
</div>
|
||
<div style={{display:'flex',alignItems:'center',gap:16}}>
|
||
<PieChart width={110} height={110}>
|
||
<Pie data={pieData} cx={50} cy={50} innerRadius={32} outerRadius={50}
|
||
dataKey="value" startAngle={90} endAngle={-270}>
|
||
{pieData.map((e,i)=><Cell key={i} fill={e.color}/>)}
|
||
</Pie>
|
||
<Tooltip formatter={(v,n)=>[`${v}%`,n]}/>
|
||
</PieChart>
|
||
<div style={{flex:1}}>
|
||
{pieData.map(p=>(
|
||
<div key={p.name} style={{display:'flex',alignItems:'center',gap:8,marginBottom:7}}>
|
||
<div style={{width:10,height:10,borderRadius:2,background:p.color,flexShrink:0}}/>
|
||
<div style={{flex:1,fontSize:13}}>{p.name}</div>
|
||
<div style={{fontSize:13,fontWeight:600,color:p.color}}>{p.value}%</div>
|
||
<div style={{fontSize:11,color:'var(--text3)'}}>{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g</div>
|
||
{p.name==='Protein' && <div style={{fontSize:10,color:proteinOk?'var(--accent)':'var(--warn)',marginLeft:2}}>
|
||
{proteinOk?'✓':'⚠️'} Ziel {ptLow}g
|
||
</div>}
|
||
</div>
|
||
))}
|
||
<div style={{marginTop:6,fontSize:11,color:'var(--text3)',borderTop:'1px solid var(--border)',paddingTop:6}}>
|
||
Gesamt: {avgKcal} kcal/Tag
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weekly stacked bars */}
|
||
{weeklyData.length>=2 && (
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Makros pro Woche (Ø g/Tag)</div>
|
||
<ResponsiveContainer width="100%" height={150}>
|
||
<BarChart data={weeklyData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||
<XAxis dataKey="label" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}/>
|
||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
|
||
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
|
||
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
|
||
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
|
||
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[3,3,0,0]}/>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||
</div>
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Activity Section ──────────────────────────────────────────────────────────
|
||
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
const [period, setPeriod] = useState(30)
|
||
if (!activities?.length) return (
|
||
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
|
||
)
|
||
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
||
const filtA = activities.filter(d=>period===9999||d.date>=cutoff)
|
||
|
||
const byDate={}
|
||
filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) })
|
||
const cd=Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).map(([date,kcal])=>({date:fmtDate(date),kcal:Math.round(kcal)}))
|
||
|
||
const totalKcal=Math.round(filtA.reduce((s,a)=>s+(a.kcal_active||0),0))
|
||
const totalMin =Math.round(filtA.reduce((s,a)=>s+(a.duration_min||0),0))
|
||
const hrData =filtA.filter(a=>a.hr_avg)
|
||
const avgHr =hrData.length?Math.round(hrData.reduce((s,a)=>s+a.hr_avg,0)/hrData.length):null
|
||
const types={}; filtA.forEach(a=>{ types[a.activity_type]=(types[a.activity_type]||0)+1 })
|
||
const topTypes=Object.entries(types).sort((a,b)=>b[1]-a[1])
|
||
|
||
const daysWithAct=new Set(filtA.map(a=>a.date)).size
|
||
const totalDays=Math.min(period,dayjs().diff(dayjs(filtA[filtA.length-1]?.date),'day')+1)
|
||
const consistency=totalDays>0?Math.round(daysWithAct/totalDays*100):0
|
||
const actRules=[{
|
||
status:consistency>=70?'good':consistency>=40?'warn':'bad',
|
||
icon:'📅', category:'Konsistenz',
|
||
title:`${consistency}% aktive Tage (${daysWithAct}/${Math.min(period,30)} Tage)`,
|
||
detail:consistency>=70?'Ausgezeichnete Regelmäßigkeit.':consistency>=40?'Ziel: 4–5 Einheiten/Woche.':'Mehr Regelmäßigkeit empfohlen.',
|
||
value:consistency+'%'
|
||
}]
|
||
|
||
return (
|
||
<div>
|
||
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
|
||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
||
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
|
||
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
|
||
avgHr?['Ø HF',avgHr+' bpm','#D85A30']:null].filter(Boolean).map(([l,v,c])=>(
|
||
<div key={l} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||
<div style={{fontSize:14,fontWeight:700,color:c}}>{v}</div>
|
||
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Aktive Kalorien / Tag</div>
|
||
<ResponsiveContainer width="100%" height={150}>
|
||
<BarChart data={cd} 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(cd.length/6)-1)}/>
|
||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||
formatter={v=>[`${v} kcal`]}/>
|
||
<Bar dataKey="kcal" fill="#EF9F2788" radius={[3,3,0,0]}/>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingsarten</div>
|
||
{topTypes.map(([type,count])=>(
|
||
<div key={type} style={{display:'flex',alignItems:'center',gap:8,padding:'4px 0',borderBottom:'1px solid var(--border)'}}>
|
||
<div style={{flex:1,fontSize:13}}>{type}</div>
|
||
<div style={{fontSize:12,color:'var(--text3)'}}>{count}×</div>
|
||
<div style={{width:Math.max(4,Math.round(count/filtA.length*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingstyp-Verteilung</div>
|
||
<TrainingTypeDistribution days={period === 9999 ? 365 : period} />
|
||
</div>
|
||
<div style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||
</div>
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
||
</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
|
||
|
||
// Chart 1: Kcal vs Weight
|
||
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
|
||
|
||
// Chart 2: 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"/>
|
||
|
||
{/* Chart 1: Kcal vs Weight */}
|
||
<div className="card" style={{marginBottom:12}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||
📉 Kalorien (Ø 7T) vs. Gewicht
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={190}>
|
||
<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={['auto','auto']}/>
|
||
<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="var(--text3)" strokeDasharray="3 3" strokeWidth={1}/>
|
||
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
|
||
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2.5} dot={{r:2,fill:'#378ADD'}} name="weight"/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
||
Gestrichelt: geschätzter TDEE {tdee} kcal · <span style={{color:'#EF9F27'}}>— Kalorien</span> · <span style={{color:'#378ADD'}}>— Gewicht</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chart 2: 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)
|
||
useEffect(()=>{ api.listPhotos().then(setPhotos) },[])
|
||
if(!photos.length) return <EmptySection text="Noch keine Fotos." to="/circum" toLabel="Umfänge erfassen"/>
|
||
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)}>
|
||
<img src={api.photoUrl(big)} style={{maxWidth:'100%',maxHeight:'100%',borderRadius:8}} alt=""/>
|
||
</div>}
|
||
<div className="photo-grid">
|
||
{photos.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}}>
|
||
{p.date?.slice(0,10)||p.created?.slice(0,10)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||
const TABS = [
|
||
{ id:'body', label:'⚖️ Körper' },
|
||
{ id:'nutrition', label:'🍽️ Ernährung' },
|
||
{ id:'activity', label:'🏋️ Aktivität' },
|
||
{ id:'correlation', label:'🔗 Korrelation' },
|
||
{ id:'photos', label:'📷 Fotos' },
|
||
]
|
||
|
||
export default function History() {
|
||
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(200),
|
||
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() },[])
|
||
|
||
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>
|
||
<h1 className="page-title">Verlauf & Auswertung</h1>
|
||
<div style={{display:'flex',gap:6,overflowX:'auto',paddingBottom:6,marginBottom:16,
|
||
msOverflowStyle:'none',scrollbarWidth:'none'}}>
|
||
{TABS.map(t=>(
|
||
<button key={t.id} onClick={()=>setTab(t.id)}
|
||
style={{whiteSpace:'nowrap',padding:'7px 14px',borderRadius:20,flexShrink:0,
|
||
border:`1.5px solid ${tab===t.id?'var(--accent)':'var(--border2)'}`,
|
||
background:tab===t.id?'var(--accent)':'var(--surface)',
|
||
color:tab===t.id?'white':'var(--text2)',
|
||
fontFamily:'var(--font)',fontSize:13,fontWeight:500,cursor:'pointer'}}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
|
||
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
|
||
{tab==='activity' && <ActivitySection activities={activities} {...sp}/>}
|
||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||
{tab==='photos' && <PhotoGrid/>}
|
||
</div>
|
||
)
|
||
}
|