mitai-jinkendo/frontend/src/pages/NutritionPage.jsx
Lars Stommer 5091dfd1ce
Some checks failed
Deploy to Raspberry Pi / deploy (push) Failing after 12s
Build Test / build-frontend (push) Failing after 2s
Build Test / lint-backend (push) Failing after 1s
fix: add auth token to all API calls (prompts, insights, pipeline)
2026-03-16 16:52:36 +01:00

435 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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, 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;&#10;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),
api.getActiveProfile(),
])
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>
)
}