mitai-jinkendo/frontend/src/pages/NutritionPage.jsx

996 lines
42 KiB
React
Raw Normal View History

import { useState, useEffect, useRef } from 'react'
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
import UsageBadge from '../components/UsageBadge'
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
} from 'recharts'
import { api as nutritionApi } from '../utils/api'
import { MACRO_CHART } from '../utils/macroChartTheme'
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}{e.macro_origin ? ` · Makros: ${e.macro_origin}` : ''}
{e.mark_type ? ` · ${e.mark_type === 'fasting' ? 'Fastentag' : 'unvollständig'}` : ''}
</div>
)}
<div style={{marginTop:8}}>
<DayMarkButtons date={e.date} markType={e.mark_type} onChanged={onUpdate} />
</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, usage = null }) {
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 [overwrite, setOverwrite] = useState(false)
const [conflicts, setConflicts] = useState([])
const atLimit = usage && !usage.allowed
const runImport = async (file) => {
if (atLimit) return
setStatus('loading'); setError(null); setConflicts([])
try {
const result = await nutritionApi.importCsv(file, overwrite)
if (result.days_imported === undefined) throw new Error(JSON.stringify(result))
setStatus(result)
setConflicts(result.conflicts || [])
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() || atLimit) 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 badge-container-right">
<span>📥 FDDB CSV Import</span>
{usage && <UsageBadge {...usage} />}
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
Zeilen (Name, Menge, Uhrzeit) werden gespeichert, wenn die CSV sie enthält.
</p>
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,marginBottom:10}}>
<input type="checkbox" checked={overwrite} onChange={(e)=>setOverwrite(e.target.checked)} />
Vorhandene Tagesmakros überschreiben (Initialimport)
</label>
{/* 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=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
onDragLeave={()=>setDragging(false)}
onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; handleDrop(e) }}
onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
style={{
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
borderRadius:10, padding:'24px 16px', textAlign:'center',
background: dragging?'var(--accent-light)':'var(--surface2)',
cursor:atLimit?'not-allowed':'pointer', opacity:atLimit?0.65:1, 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)'}}>
{atLimit
? '🔒 Limit erreicht — Import nicht möglich'
: 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'}} disabled={atLimit} 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',
opacity:atLimit?0.65:1}}
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)}
disabled={atLimit}
/>
<button className="btn btn-primary btn-full" style={{marginTop:8}}
onClick={handlePasteImport}
disabled={status==='loading'||!pasteText.trim()||atLimit}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}>
{atLimit
? '🔒 Limit erreicht'
: 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 · {status.rows_parsed} Zeilen{status.items_written ? ` · ${status.items_written} Items` : ''}</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>
)}
{conflicts.length > 0 && (
<ConflictDialog conflicts={conflicts} onDone={() => { setConflicts([]); onImported() }} />
)}
</div>
)
}
function ConflictDialog({ conflicts, onDone }) {
const [choices, setChoices] = useState({})
const [busy, setBusy] = useState(false)
const setC = (date, choice) => setChoices((s) => ({ ...s, [date]: choice }))
const save = async () => {
setBusy(true)
try {
const decisions = conflicts.map((c) => ({ date: c.date, choice: choices[c.date] || 'existing' }))
await nutritionApi.resolveNutritionConflicts(decisions)
onDone()
} catch (e) {
alert(e.message)
} finally {
setBusy(false)
}
}
return (
<div style={{marginTop:12,padding:12,border:'1px solid var(--border)',borderRadius:8}}>
<strong>Abweichende Tagesmakros</strong>
{conflicts.map((c) => (
<div key={c.date} style={{marginTop:10,fontSize:12}}>
<div>{c.date}</div>
<div>Ist {c.existing?.kcal} · FDDB {c.fddb?.kcal} · Katalog {c.catalog?.kcal} kcal</div>
{['existing','fddb','catalog'].map((k) => (
<label key={k} style={{marginRight:10}}>
<input type="radio" name={`c-${c.date}`} checked={(choices[c.date]||'existing')===k} onChange={()=>setC(c.date,k)} />
{k === 'existing' ? 'behalten' : k === 'fddb' ? 'FDDB' : 'Katalog'}
</label>
))}
</div>
))}
<button type="button" className="btn btn-primary" style={{marginTop:10}} disabled={busy} onClick={save}>Übernehmen</button>
</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 }) {
// 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={MACRO_CHART.protein}/>
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat}/>
<Bar dataKey="Kohlenhydrate" stackId="a" fill={MACRO_CHART.carbs} 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 [nutritionUsage, setNutritionUsage] = useState(null)
const loadUsage = () => {
nutritionApi.getFeatureUsage().then(features => {
const nutritionFeature = features.find(f => f.feature_id === 'nutrition_entries')
setNutritionUsage(nutritionFeature ?? null)
}).catch(err => console.error('Failed to load usage:', err))
}
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(); loadUsage() }, [])
return (
<div className="capture-page">
<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>
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
Zuordnen
</button>
</div>
{/* Entry Form */}
{inputTab==='entry' && <EntryForm onSaved={load}/>}
{/* Import Panel + History */}
{inputTab==='import' && (
<>
<ImportPanel
usage={nutritionUsage}
onImported={() => { load(); loadUsage(); setImportHistoryKey(Date.now()) }}
/>
<ImportHistory key={importHistoryKey}/>
</>
)}
{inputTab==='map' && <NutritionFoodMap onChanged={load} />}
{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 ~1–2 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,6–2,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>
)
}