mitai-jinkendo/frontend/src/pages/NutritionPage.jsx
Lars 888b5c3e40
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
fix: [BUG-003] correlations chart shows all weight data with extrapolation
Changes:
- Show all data points (kcal OR weight, not only both)
- Extrapolate missing kcal values at end (use last known value)
- Dashed lines (strokeDasharray) for extrapolated values
- Solid lines for real measurements
- Weight always interpolates gaps (connectNulls=true)

Visual distinction:
- Solid = Real measurements + gap interpolation
- Dashed = Extrapolation at chart end

Closes: BUG-003

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:51:20 +01:00

912 lines
38 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, 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
})
}
// ── Entry Form (Create/Update) ───────────────────────────────────────────────
function EntryForm({ onSaved }) {
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
const [existingId, setExistingId] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
// Load data for selected date
useEffect(() => {
const load = async () => {
if (!date) return
setLoading(true)
setError(null)
try {
const data = await nutritionApi.getNutritionByDate(date)
if (data) {
setValues({
kcal: data.kcal || '',
protein_g: data.protein_g || '',
fat_g: data.fat_g || '',
carbs_g: data.carbs_g || ''
})
setExistingId(data.id)
} else {
setValues({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
setExistingId(null)
}
} catch(e) {
console.error('Failed to load entry:', e)
} finally {
setLoading(false)
}
}
load()
}, [date])
const handleSave = async () => {
if (!date || !values.kcal) {
setError('Datum und Kalorien sind Pflichtfelder')
return
}
setSaving(true)
setError(null)
setSuccess(null)
try {
const result = await nutritionApi.createNutrition(
date,
parseFloat(values.kcal) || 0,
parseFloat(values.protein_g) || 0,
parseFloat(values.fat_g) || 0,
parseFloat(values.carbs_g) || 0
)
setSuccess(result.mode === 'created' ? 'Eintrag hinzugefügt' : 'Eintrag aktualisiert')
setTimeout(() => setSuccess(null), 3000)
onSaved()
} catch(e) {
if (e.message.includes('Limit erreicht')) {
setError(e.message)
} else {
setError('Speichern fehlgeschlagen: ' + e.message)
}
setTimeout(() => setError(null), 5000)
} finally {
setSaving(false)
}
}
return (
<div className="card section-gap">
<div className="card-title">Eintrag hinzufügen / bearbeiten</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
{error}
</div>
)}
{success && (
<div style={{padding:'8px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)',marginBottom:12}}>
{success}
</div>
)}
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:12,marginBottom:12}}>
<div style={{gridColumn:'1 / -1'}}>
<label className="form-label">Datum</label>
<input
type="date"
className="form-input"
value={date}
onChange={e => setDate(e.target.value)}
max={dayjs().format('YYYY-MM-DD')}
style={{width:'100%'}}
/>
{existingId && !loading && (
<div style={{fontSize:11,color:'var(--accent)',marginTop:4}}>
Eintrag existiert bereits wird beim Speichern aktualisiert
</div>
)}
</div>
<div>
<label className="form-label">Kalorien *</label>
<input
type="number"
className="form-input"
value={values.kcal}
onChange={e => setValues({...values, kcal: e.target.value})}
placeholder="z.B. 2000"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Protein (g)</label>
<input
type="number"
className="form-input"
value={values.protein_g}
onChange={e => setValues({...values, protein_g: e.target.value})}
placeholder="z.B. 150"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Fett (g)</label>
<input
type="number"
className="form-input"
value={values.fat_g}
onChange={e => setValues({...values, fat_g: e.target.value})}
placeholder="z.B. 80"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Kohlenhydrate (g)</label>
<input
type="number"
className="form-input"
value={values.carbs_g}
onChange={e => setValues({...values, carbs_g: e.target.value})}
placeholder="z.B. 200"
disabled={loading}
style={{width:'100%'}}
/>
</div>
</div>
<button
className="btn btn-primary btn-full"
onClick={handleSave}
disabled={saving || loading || !date || !values.kcal}>
{saving ? (
<><div className="spinner" style={{width:14,height:14}}/> Speichere</>
) : existingId ? (
'📝 Eintrag aktualisieren'
) : (
' Eintrag hinzufügen'
)}
</button>
</div>
)
}
// ── Data Tab (Editable Entry List) ───────────────────────────────────────────
function DataTab({ entries, onUpdate }) {
const [editId, setEditId] = useState(null)
const [editValues, setEditValues] = useState({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [filter, setFilter] = useState('30') // days to show (7, 30, 90, 'all')
const startEdit = (e) => {
setEditId(e.id)
setEditValues({
kcal: e.kcal || 0,
protein_g: e.protein_g || 0,
fat_g: e.fat_g || 0,
carbs_g: e.carbs_g || 0
})
}
const cancelEdit = () => {
setEditId(null)
setEditValues({})
setError(null)
}
const saveEdit = async (id) => {
setSaving(true)
setError(null)
try {
await nutritionApi.updateNutrition(
id,
editValues.kcal,
editValues.protein_g,
editValues.fat_g,
editValues.carbs_g
)
setEditId(null)
setEditValues({})
onUpdate()
} catch(e) {
setError('Speichern fehlgeschlagen: ' + e.message)
} finally {
setSaving(false)
}
}
const deleteEntry = async (id, date) => {
if (!confirm(`Eintrag vom ${dayjs(date).format('DD.MM.YYYY')} wirklich löschen?`)) return
try {
await nutritionApi.deleteNutrition(id)
onUpdate()
} catch(e) {
setError('Löschen fehlgeschlagen: ' + e.message)
}
}
// Filter entries by date range
const filteredEntries = filter === 'all'
? entries
: entries.filter(e => {
const daysDiff = dayjs().diff(dayjs(e.date), 'day')
return daysDiff <= parseInt(filter)
})
if (entries.length === 0) {
return (
<div className="card section-gap">
<div className="card-title">Alle Einträge (0)</div>
<p className="muted">Noch keine Ernährungsdaten. Importiere FDDB CSV oben.</p>
</div>
)
}
return (
<div className="card section-gap">
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
<div className="card-title" style={{margin:0}}>
Alle Einträge ({filteredEntries.length}{filteredEntries.length !== entries.length ? ` von ${entries.length}` : ''})
</div>
<select
value={filter}
onChange={e => setFilter(e.target.value)}
style={{
padding:'6px 10px',fontSize:12,borderRadius:8,border:'1.5px solid var(--border2)',
background:'var(--surface)',color:'var(--text2)',cursor:'pointer',fontFamily:'var(--font)'
}}>
<option value="7">Letzte 7 Tage</option>
<option value="30">Letzte 30 Tage</option>
<option value="90">Letzte 90 Tage</option>
<option value="365">Letztes Jahr</option>
<option value="all">Alle anzeigen</option>
</select>
</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
{error}
</div>
)}
{filteredEntries.map((e, i) => {
const isEditing = editId === e.id
return (
<div key={e.id || i} style={{
borderBottom: i < filteredEntries.length - 1 ? '1px solid var(--border)' : 'none',
padding: '12px 0'
}}>
{!isEditing ? (
<>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:6}}>
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
<div style={{display:'flex',gap:6}}>
<button onClick={() => startEdit(e)}
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid var(--border2)',
background:'var(--surface)',color:'var(--text2)',cursor:'pointer'}}>
Bearbeiten
</button>
<button onClick={() => deleteEntry(e.id, e.date)}
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid #D85A30',
background:'#FCEBEB',color:'#D85A30',cursor:'pointer'}}>
🗑
</button>
</div>
</div>
<div style={{fontSize:13, fontWeight:600, color:'#EF9F27',marginBottom:6}}>
{Math.round(e.kcal || 0)} kcal
</div>
<div style={{display:'flex', gap:12, fontSize:12, color:'var(--text2)'}}>
<span>🥩 Protein: <strong>{Math.round(e.protein_g || 0)}g</strong></span>
<span>🫙 Fett: <strong>{Math.round(e.fat_g || 0)}g</strong></span>
<span>🍞 Kohlenhydrate: <strong>{Math.round(e.carbs_g || 0)}g</strong></span>
</div>
{e.source && (
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
Quelle: {e.source}
</div>
)}
</>
) : (
<>
<div style={{marginBottom:8}}>
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kalorien</label>
<input type="number" className="form-input" value={editValues.kcal}
onChange={e => setEditValues({...editValues, kcal: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Protein (g)</label>
<input type="number" className="form-input" value={editValues.protein_g}
onChange={e => setEditValues({...editValues, protein_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Fett (g)</label>
<input type="number" className="form-input" value={editValues.fat_g}
onChange={e => setEditValues({...editValues, fat_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kohlenhydrate (g)</label>
<input type="number" className="form-input" value={editValues.carbs_g}
onChange={e => setEditValues({...editValues, carbs_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={() => saveEdit(e.id)} disabled={saving}
className="btn btn-primary" style={{flex:1}}>
{saving ? 'Speichere…' : '✓ Speichern'}
</button>
<button onClick={cancelEdit} disabled={saving}
className="btn btn-secondary" style={{flex:1}}>
Abbrechen
</button>
</div>
</>
)}
</div>
)
})}
</div>
)
}
// ── Import History ────────────────────────────────────────────────────────────
function ImportHistory() {
const [history, setHistory] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
const data = await nutritionApi.nutritionImportHistory()
setHistory(Array.isArray(data) ? data : [])
} catch(e) {
console.error('Failed to load import history:', e)
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) return null
if (!history.length) return null
return (
<div className="card section-gap">
<div className="card-title">Import-Historie</div>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{history.map((h, i) => (
<div key={i} style={{
padding: '10px 12px',
background: 'var(--surface2)',
borderRadius: 8,
borderLeft: '3px solid var(--accent)',
fontSize: 13
}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:4}}>
<strong>{dayjs(h.import_date).format('DD.MM.YYYY')}</strong>
<span style={{color:'var(--text3)',fontSize:11}}>
{dayjs(h.last_created).format('HH:mm')} Uhr
</span>
</div>
<div style={{color:'var(--text2)',fontSize:12}}>
<span>{h.count} {h.count === 1 ? 'Eintrag' : 'Einträge'}</span>
{h.date_from && h.date_to && (
<span style={{marginLeft:8,color:'var(--text3)'}}>
({dayjs(h.date_from).format('DD.MM.YY')} {dayjs(h.date_to).format('DD.MM.YY')})
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
// ── 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,62,2 g/kg Körpergewicht für Muskelaufbau
</div>
</div>
)
}
// ── Chart: Kalorien vs Gewicht ────────────────────────────────────────────────
function CaloriesVsWeight({ data }) {
// BUG-003 fix: Show all weight data, extrapolate kcal if missing
const filtered = data.filter(d => d.kcal || d.weight)
if (filtered.length < 3) return (
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
Zu wenig Daten für diese Auswertung
</div>
)
// Find last real kcal value
const lastKcalIndex = filtered.findLastIndex(d => d.kcal)
const lastKcal = lastKcalIndex >= 0 ? filtered[lastKcalIndex].kcal : null
// Extrapolate missing kcal values at the end
const withExtrapolated = filtered.map((d, i) => ({
...d,
kcal: d.kcal || (i > lastKcalIndex && lastKcal ? lastKcal : null),
isKcalExtrapolated: !d.kcal && i > lastKcalIndex && lastKcal
}))
// Format dates and calculate rolling average
const formatted = withExtrapolated.map(d => ({
...d,
date: dayjs(d.date).format('DD.MM')
}))
const withAvg = rollingAvg(formatted, 'kcal')
// Split into real and extrapolated segments for dashed lines
const realData = withAvg.map(d => ({
...d,
kcal_extrap: d.isKcalExtrapolated ? d.kcal : null,
kcal_avg_extrap: d.isKcalExtrapolated ? d.kcal_avg : null,
kcal: d.isKcalExtrapolated ? null : d.kcal,
kcal_avg: d.isKcalExtrapolated ? null : d.kcal_avg
}))
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={realData} 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(realData.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.includes('avg')?'Ø 7T Kalorien':n==='weight'?'Gewicht':'Kalorien']}/>
{/* Real kcal values - solid lines */}
<Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} connectNulls={false}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg" connectNulls={false}/>
{/* Extrapolated kcal values - dashed lines */}
<Line yAxisId="kcal" type="monotone" dataKey="kcal_extrap" stroke="#EF9F2744" strokeWidth={1} strokeDasharray="3 3" dot={false} connectNulls={false}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg_extrap" stroke="#EF9F27" strokeWidth={2} strokeDasharray="3 3" dot={false} connectNulls={false}/>
{/* Weight - always solid */}
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2} dot={{r:3,fill:'#378ADD'}} name="weight" connectNulls={true}/>
</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 [inputTab, setInputTab] = useState('entry') // 'entry' or 'import'
const [analysisTab,setAnalysisTab] = useState('data')
const [corrData, setCorr] = useState([])
const [weekly, setWeekly] = useState([])
const [entries, setEntries]= useState([])
const [profile, setProf] = useState(null)
const [loading, setLoad] = useState(true)
const [hasData, setHasData]= useState(false)
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
const load = async () => {
setLoad(true)
try {
const [corr, wkly, ent, prof] = await Promise.all([
nutritionApi.nutritionCorrelations(),
nutritionApi.nutritionWeekly(16),
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
nutritionApi.getActiveProfile(),
])
setCorr(Array.isArray(corr)?corr:[])
setWeekly(Array.isArray(wkly)?wkly:[])
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
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>
{/* Input Method Tabs */}
<div className="tabs section-gap" style={{marginBottom:0}}>
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
Einzelerfassung
</button>
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
📥 Import
</button>
</div>
{/* Entry Form */}
{inputTab==='entry' && <EntryForm onSaved={load}/>}
{/* Import Panel + History */}
{inputTab==='import' && (
<>
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
<ImportHistory key={importHistoryKey}/>
</>
)}
{loading && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && (
<div className="empty-state">
<h3>Noch keine Ernährungsdaten</h3>
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
</div>
)}
{/* Analysis Section */}
{!loading && hasData && (
<>
<OverviewCards data={corrData}/>
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(analysisTab==='data'?' active':'')} onClick={()=>setAnalysisTab('data')}>Daten</button>
<button className={'tab'+(analysisTab==='overview'?' active':'')} onClick={()=>setAnalysisTab('overview')}>Übersicht</button>
<button className={'tab'+(analysisTab==='weight'?' active':'')} onClick={()=>setAnalysisTab('weight')}>Kcal vs. Gewicht</button>
<button className={'tab'+(analysisTab==='protein'?' active':'')} onClick={()=>setAnalysisTab('protein')}>Protein vs. Mager</button>
<button className={'tab'+(analysisTab==='balance'?' active':'')} onClick={()=>setAnalysisTab('balance')}>Bilanz</button>
</div>
{analysisTab==='data' && <DataTab entries={entries} onUpdate={load}/>}
{analysisTab==='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>
)}
{analysisTab==='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 ~12 Wochen Verzögerung.<br/>
7-Tage-Glättung filtert tägliche Schwankungen heraus.
</div>
</div>
)}
{analysisTab==='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,62,2 g Protein pro kg Körpergewicht täglich.
</div>
</div>
)}
{analysisTab==='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>
)
}