435 lines
21 KiB
React
435 lines
21 KiB
React
|
|
import { useState, useEffect, useRef } from 'react'
|
|||
|
|
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
|||
|
|
import {
|
|||
|
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
|||
|
|
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
|||
|
|
} from 'recharts'
|
|||
|
|
import { api as nutritionApi } from '../utils/api'
|
|||
|
|
import dayjs from 'dayjs'
|
|||
|
|
import isoWeek from 'dayjs/plugin/isoWeek'
|
|||
|
|
dayjs.extend(isoWeek)
|
|||
|
|
|
|||
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|||
|
|
const KCAL_PER_KG_FAT = 7700
|
|||
|
|
function rollingAvg(arr, key, window=7) {
|
|||
|
|
return arr.map((d,i) => {
|
|||
|
|
const slice = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
|
|||
|
|
return slice.length ? {...d, [`${key}_avg`]: Math.round(slice.reduce((a,b)=>a+b,0)/slice.length*10)/10} : d
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
|||
|
|
function ImportPanel({ onImported }) {
|
|||
|
|
const fileRef = useRef()
|
|||
|
|
const [status, setStatus] = useState(null)
|
|||
|
|
const [error, setError] = useState(null)
|
|||
|
|
const [dragging,setDragging]= useState(false)
|
|||
|
|
const [tab, setTab] = useState('file') // 'file' | 'paste'
|
|||
|
|
const [pasteText, setPasteText] = useState('')
|
|||
|
|
|
|||
|
|
const runImport = async (file) => {
|
|||
|
|
setStatus('loading'); setError(null)
|
|||
|
|
try {
|
|||
|
|
const result = await nutritionApi.importCsv(file)
|
|||
|
|
if (result.days_imported === undefined) throw new Error(JSON.stringify(result))
|
|||
|
|
setStatus(result)
|
|||
|
|
onImported()
|
|||
|
|
} catch(err) {
|
|||
|
|
setError('Import fehlgeschlagen: ' + err.message)
|
|||
|
|
setStatus(null)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleFile = async e => {
|
|||
|
|
const file = e.target.files[0]; if (!file) return
|
|||
|
|
await runImport(file)
|
|||
|
|
e.target.value = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleDrop = async e => {
|
|||
|
|
e.preventDefault(); setDragging(false)
|
|||
|
|
const file = e.dataTransfer.files[0]
|
|||
|
|
if (!file) return
|
|||
|
|
await runImport(file)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handlePasteImport = async () => {
|
|||
|
|
if (!pasteText.trim()) return
|
|||
|
|
const blob = new Blob([pasteText], { type: 'text/csv' })
|
|||
|
|
const file = new File([blob], 'paste.csv', { type: 'text/csv' })
|
|||
|
|
await runImport(file)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div className="card-title">📥 FDDB CSV Import</div>
|
|||
|
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
|||
|
|
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
{/* Tab switcher */}
|
|||
|
|
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
|||
|
|
{[['file','📁 Datei / Drag & Drop'],['paste','📋 Text einfügen']].map(([k,l])=>(
|
|||
|
|
<button key={k} onClick={()=>setTab(k)}
|
|||
|
|
style={{flex:1,padding:'7px 10px',borderRadius:8,border:`1.5px solid ${tab===k?'var(--accent)':'var(--border2)'}`,
|
|||
|
|
background:tab===k?'var(--accent-light)':'var(--surface)',
|
|||
|
|
color:tab===k?'var(--accent-dark)':'var(--text2)',
|
|||
|
|
fontFamily:'var(--font)',fontSize:12,fontWeight:500,cursor:'pointer'}}>
|
|||
|
|
{l}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{tab==='file' && (
|
|||
|
|
<>
|
|||
|
|
{/* Drag & Drop Zone */}
|
|||
|
|
<div
|
|||
|
|
onDragOver={e=>{e.preventDefault();setDragging(true)}}
|
|||
|
|
onDragLeave={()=>setDragging(false)}
|
|||
|
|
onDrop={handleDrop}
|
|||
|
|
onClick={()=>fileRef.current.click()}
|
|||
|
|
style={{
|
|||
|
|
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
|
|||
|
|
borderRadius:10, padding:'24px 16px', textAlign:'center',
|
|||
|
|
background: dragging?'var(--accent-light)':'var(--surface2)',
|
|||
|
|
cursor:'pointer', transition:'all 0.15s',
|
|||
|
|
}}>
|
|||
|
|
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
|
|||
|
|
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
|||
|
|
{dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
|
|||
|
|
</div>
|
|||
|
|
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
|
|||
|
|
</div>
|
|||
|
|
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} onChange={handleFile}/>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab==='paste' && (
|
|||
|
|
<>
|
|||
|
|
<textarea
|
|||
|
|
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
|
|||
|
|
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
|||
|
|
color:'var(--text1)',resize:'vertical',boxSizing:'border-box'}}
|
|||
|
|
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung; 13.03.2026 21:54;50 g Hähnchen;..."
|
|||
|
|
value={pasteText}
|
|||
|
|
onChange={e=>setPasteText(e.target.value)}
|
|||
|
|
/>
|
|||
|
|
<button className="btn btn-primary btn-full" style={{marginTop:8}}
|
|||
|
|
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}>
|
|||
|
|
{status==='loading'
|
|||
|
|
? <><div className="spinner" style={{width:14,height:14}}/> Importiere…</>
|
|||
|
|
: <><Upload size={15}/> CSV-Text importieren</>}
|
|||
|
|
</button>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{status==='loading' && (
|
|||
|
|
<div style={{marginTop:10,display:'flex',alignItems:'center',gap:8,fontSize:13,color:'var(--text2)'}}>
|
|||
|
|
<div className="spinner" style={{width:16,height:16}}/> Importiere…
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{error && (
|
|||
|
|
<div style={{marginTop:8,padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30'}}>
|
|||
|
|
{error}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{status && status !== 'loading' && (
|
|||
|
|
<div style={{marginTop:8,padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)'}}>
|
|||
|
|
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}>
|
|||
|
|
<CheckCircle size={15}/><strong>Import erfolgreich</strong>
|
|||
|
|
</div>
|
|||
|
|
<div>{status.days_imported} Tage importiert · {status.rows_parsed} Einträge verarbeitet</div>
|
|||
|
|
{status.date_range?.from && (
|
|||
|
|
<div style={{fontSize:11,marginTop:2}}>
|
|||
|
|
{dayjs(status.date_range.from).format('DD.MM.YYYY')} – {dayjs(status.date_range.to).format('DD.MM.YYYY')}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Overview Cards ────────────────────────────────────────────────────────────
|
|||
|
|
function OverviewCards({ data }) {
|
|||
|
|
if (!data.length) return null
|
|||
|
|
const last7 = data.filter(d=>d.kcal).slice(-7)
|
|||
|
|
if (!last7.length) return null
|
|||
|
|
const avg = key => Math.round(last7.map(d=>d[key]||0).reduce((a,b)=>a+b,0)/last7.length)
|
|||
|
|
const kcal = avg('kcal'), prot = avg('protein_g'), fat = avg('fat_g'), carbs = avg('carbs_g')
|
|||
|
|
const total_g = prot + fat + carbs
|
|||
|
|
return (
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div className="card-title">Ø letzte 7 Tage</div>
|
|||
|
|
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8}}>
|
|||
|
|
{[
|
|||
|
|
['🔥 Kalorien', kcal, 'kcal', '#EF9F27'],
|
|||
|
|
['🥩 Protein', prot, 'g', '#1D9E75'],
|
|||
|
|
['🫙 Fett', fat, 'g', '#378ADD'],
|
|||
|
|
['🍞 Kohlenhydrate', carbs, 'g', '#D4537E'],
|
|||
|
|
].map(([label, val, unit, color]) => (
|
|||
|
|
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
|
|||
|
|
<div style={{fontSize:20,fontWeight:700,color}}>{val}<span style={{fontSize:12,color:'var(--text3)',marginLeft:2}}>{unit}</span></div>
|
|||
|
|
<div style={{fontSize:11,color:'var(--text3)'}}>{label}</div>
|
|||
|
|
{unit==='g' && total_g>0 && <div style={{fontSize:10,color:'var(--text3)'}}>{Math.round(val/total_g*100)}% der Makros</div>}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<div style={{marginTop:8,padding:'6px 10px',background:'var(--surface2)',borderRadius:8,fontSize:12,color:'var(--text3)'}}>
|
|||
|
|
<Info size={11} style={{marginRight:4,verticalAlign:'middle'}}/>
|
|||
|
|
Protein-Ziel: 1,6–2,2 g/kg Körpergewicht für Muskelaufbau
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Chart: Kalorien vs Gewicht ────────────────────────────────────────────────
|
|||
|
|
function CaloriesVsWeight({ data }) {
|
|||
|
|
const filtered = data.filter(d => d.kcal && d.weight)
|
|||
|
|
const withAvg = rollingAvg(filtered.map(d=>({...d,date:dayjs(d.date).format('DD.MM')})), 'kcal')
|
|||
|
|
if (filtered.length < 3) return (
|
|||
|
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
|
|||
|
|
Zu wenig gemeinsame Daten (Gewicht + Kalorien am selben Tag nötig)
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={220}>
|
|||
|
|
<LineChart data={withAvg} 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(withAvg.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'?'Ø 7T Kalorien':n==='weight'?'Gewicht':'Kalorien']}/>
|
|||
|
|
<Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
|
|||
|
|
<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} dot={{r:3,fill:'#378ADD'}} name="weight"/>
|
|||
|
|
</LineChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Chart: Protein vs Magermasse ──────────────────────────────────────────────
|
|||
|
|
function ProteinVsLeanMass({ data }) {
|
|||
|
|
const filtered = data.filter(d => d.protein_g && d.lean_mass)
|
|||
|
|
if (filtered.length < 3) return (
|
|||
|
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
|
|||
|
|
Noch zu wenig Messungen mit Magermasse-Werten für diese Auswertung
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
const chartData = filtered.map(d=>({
|
|||
|
|
date: dayjs(d.date).format('DD.MM'),
|
|||
|
|
protein_g: d.protein_g,
|
|||
|
|
lean_mass: d.lean_mass,
|
|||
|
|
}))
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={220}>
|
|||
|
|
<LineChart data={chartData} 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(chartData.length/6)-1)}/>
|
|||
|
|
<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']}/>
|
|||
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
|||
|
|
formatter={(v,n)=>[`${Math.round(v)} ${n==='lean_mass'?'kg':'g'}`, n==='protein_g'?'Protein':'Magermasse']}/>
|
|||
|
|
<Legend wrapperStyle={{fontSize:11}}/>
|
|||
|
|
<Line yAxisId="prot" type="monotone" dataKey="protein_g" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein_g"/>
|
|||
|
|
<Line yAxisId="lean" type="monotone" dataKey="lean_mass" stroke="#7F77DD" strokeWidth={2} dot={{r:4,fill:'#7F77DD'}} name="lean_mass"/>
|
|||
|
|
</LineChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Chart: Makro-Verteilung pro Woche (Balken) ────────────────────────────────
|
|||
|
|
function WeeklyMacros({ weekly }) {
|
|||
|
|
if (!weekly.length) return null
|
|||
|
|
const data = weekly.slice(-12).map(w => ({
|
|||
|
|
week: w.week.replace(/\d{4}-/,''),
|
|||
|
|
Protein: w.protein_g,
|
|||
|
|
Fett: w.fat_g,
|
|||
|
|
'Kohlenhydrate': w.carbs_g,
|
|||
|
|
kcal: Math.round(w.kcal),
|
|||
|
|
}))
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={200}>
|
|||
|
|
<BarChart data={data} margin={{top:4,right:8,bottom:0,left:-16}}>
|
|||
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|||
|
|
<XAxis dataKey="week" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|||
|
|
<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,n)=>[`${Math.round(v)} g`, n]}/>
|
|||
|
|
<Legend wrapperStyle={{fontSize:11}}/>
|
|||
|
|
<Bar dataKey="Protein" stackId="a" fill="#1D9E75"/>
|
|||
|
|
<Bar dataKey="Fett" stackId="a" fill="#378ADD"/>
|
|||
|
|
<Bar dataKey="Kohlenhydrate" stackId="a" fill="#D4537E" radius={[3,3,0,0]}/>
|
|||
|
|
</BarChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Chart: Kaloriendefizit/-überschuss Trend ──────────────────────────────────
|
|||
|
|
function CalorieBalance({ data, profile }) {
|
|||
|
|
// Rough TDEE estimate (Mifflin-St Jeor + activity 1.55)
|
|||
|
|
const sex = profile?.sex || 'm'
|
|||
|
|
const height = profile?.height || 178
|
|||
|
|
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
|
|||
|
|
|
|||
|
|
// Use average weight from data
|
|||
|
|
const weights = data.filter(d=>d.weight).map(d=>d.weight)
|
|||
|
|
const avgWeight = weights.length ? weights.reduce((a,b)=>a+b)/weights.length : 80
|
|||
|
|
|
|||
|
|
const bmr = sex==='m'
|
|||
|
|
? 10*avgWeight + 6.25*height - 5*age + 5
|
|||
|
|
: 10*avgWeight + 6.25*height - 5*age - 161
|
|||
|
|
const tdee = Math.round(bmr * 1.55)
|
|||
|
|
|
|||
|
|
const filtered = data.filter(d=>d.kcal)
|
|||
|
|
const withAvg = rollingAvg(filtered.map(d=>({
|
|||
|
|
...d,
|
|||
|
|
date: dayjs(d.date).format('DD.MM'),
|
|||
|
|
balance: Math.round(d.kcal - tdee),
|
|||
|
|
})), 'balance')
|
|||
|
|
|
|||
|
|
if (filtered.length < 5) return (
|
|||
|
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
|
|||
|
|
Mehr Kalorieneinträge nötig für diese Auswertung
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6,textAlign:'center'}}>
|
|||
|
|
Geschätzter TDEE: <strong>{tdee} kcal</strong> · Ø Gewicht: {Math.round(avgWeight*10)/10} kg
|
|||
|
|
<span style={{marginLeft:6,opacity:0.7}}>(Mifflin-St Jeor × 1,55)</span>
|
|||
|
|
</div>
|
|||
|
|
<ResponsiveContainer width="100%" height={180}>
|
|||
|
|
<LineChart data={withAvg} 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(withAvg.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>
|
|||
|
|
</>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
|||
|
|
export default function NutritionPage() {
|
|||
|
|
const [tab, setTab] = useState('overview')
|
|||
|
|
const [corrData, setCorr] = useState([])
|
|||
|
|
const [weekly, setWeekly] = useState([])
|
|||
|
|
const [profile, setProf] = useState(null)
|
|||
|
|
const [loading, setLoad] = useState(true)
|
|||
|
|
const [hasData, setHasData]= useState(false)
|
|||
|
|
|
|||
|
|
const load = async () => {
|
|||
|
|
setLoad(true)
|
|||
|
|
try {
|
|||
|
|
const [corr, wkly, prof] = await Promise.all([
|
|||
|
|
nutritionApi.nutritionCorrelations(),
|
|||
|
|
nutritionApi.nutritionWeekly(16),
|
|||
|
|
fetch('/api/profile').then(r=>r.json()),
|
|||
|
|
])
|
|||
|
|
setCorr(Array.isArray(corr)?corr:[])
|
|||
|
|
setWeekly(Array.isArray(wkly)?wkly:[])
|
|||
|
|
setProf(prof)
|
|||
|
|
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
|||
|
|
} catch(e) { console.error('load error:', e) }
|
|||
|
|
finally { setLoad(false) }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
useEffect(() => { load() }, [])
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<h1 className="page-title">Ernährung</h1>
|
|||
|
|
|
|||
|
|
<ImportPanel onImported={load}/>
|
|||
|
|
|
|||
|
|
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
|||
|
|
|
|||
|
|
{!loading && !hasData && (
|
|||
|
|
<div className="empty-state">
|
|||
|
|
<h3>Noch keine Ernährungsdaten</h3>
|
|||
|
|
<p>Importiere deinen FDDB-Export oben um Auswertungen zu sehen.</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!loading && hasData && (
|
|||
|
|
<>
|
|||
|
|
<OverviewCards data={corrData}/>
|
|||
|
|
|
|||
|
|
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
|
|||
|
|
<button className={'tab'+(tab==='overview'?' active':'')} onClick={()=>setTab('overview')}>Übersicht</button>
|
|||
|
|
<button className={'tab'+(tab==='weight'?' active':'')} onClick={()=>setTab('weight')}>Kcal vs. Gewicht</button>
|
|||
|
|
<button className={'tab'+(tab==='protein'?' active':'')} onClick={()=>setTab('protein')}>Protein vs. Mager</button>
|
|||
|
|
<button className={'tab'+(tab==='balance'?' active':'')} onClick={()=>setTab('balance')}>Bilanz</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{tab==='overview' && (
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div>
|
|||
|
|
<WeeklyMacros weekly={weekly}/>
|
|||
|
|
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:8,fontSize:11,color:'var(--text3)'}}>
|
|||
|
|
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E75',borderRadius:2,marginRight:4}}/>Protein</span>
|
|||
|
|
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD',borderRadius:2,marginRight:4}}/>Fett</span>
|
|||
|
|
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E',borderRadius:2,marginRight:4}}/>Kohlenhydrate</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab==='weight' && (
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div className="card-title">Kalorien vs. Gewichtsverlauf</div>
|
|||
|
|
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
|||
|
|
<span style={{color:'#EF9F27',fontWeight:600}}>— Kalorien (Ø 7T)</span>
|
|||
|
|
{' '}
|
|||
|
|
<span style={{color:'#378ADD',fontWeight:600}}>— Gewicht</span>
|
|||
|
|
</div>
|
|||
|
|
<CaloriesVsWeight data={corrData}/>
|
|||
|
|
<div style={{marginTop:10,fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
|
|||
|
|
💡 Kalorien steigen → Gewicht steigt mit ~1–2 Wochen Verzögerung.<br/>
|
|||
|
|
7-Tage-Glättung filtert tägliche Schwankungen heraus.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab==='protein' && (
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div className="card-title">Protein vs. Magermasse</div>
|
|||
|
|
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
|||
|
|
<span style={{color:'#1D9E75',fontWeight:600}}>— Protein g/Tag</span>
|
|||
|
|
{' '}
|
|||
|
|
<span style={{color:'#7F77DD',fontWeight:600}}>— Magermasse kg</span>
|
|||
|
|
</div>
|
|||
|
|
<ProteinVsLeanMass data={corrData}/>
|
|||
|
|
<div style={{marginTop:10,fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
|
|||
|
|
💡 Magermasse-Punkte sind die tatsächlichen Caliper-Messungen.<br/>
|
|||
|
|
Ziel: 1,6–2,2 g Protein pro kg Körpergewicht täglich.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab==='balance' && (
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div className="card-title">Kaloriendefizit / -überschuss</div>
|
|||
|
|
<CalorieBalance data={corrData} profile={profile}/>
|
|||
|
|
<div style={{marginTop:10,fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
|
|||
|
|
💡 Über 0 = Überschuss (Aufbau), unter 0 = Defizit (Abbau).<br/>
|
|||
|
|
~500 kcal Defizit = ~0,5 kg Fettabbau pro Woche theoretisch.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|