mitai-jinkendo/frontend/src/pages/History.jsx
Lars 967d92025c
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
fix: move TrainingTypeDistribution to History + improve admin form UX
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>
2026-03-21 16:56:35 +01:00

976 lines
52 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
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 &lt;{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 &lt;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,62,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: 2535%. 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: 45 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>
)
}