mitai-jinkendo/frontend/src/pages/NutritionPage.jsx
Lars 132a364a3a
All checks were successful
Deploy Development / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s
feat: Zuordnen ohne Freeze und zuerst die letzten Wochen
Katalogsuche und Bestätigen blockieren die UI nicht mehr; offene Namen starten bei den aktuellen Tagebucheinträgen, alter Ballast bleibt unter Alle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 16:14:24 +02:00

1074 lines
46 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 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 DayItems({ date }) {
const [items, setItems] = useState(null)
const [open, setOpen] = useState(false)
const [error, setError] = useState(null)
const toggle = async () => {
if (open) {
setOpen(false)
return
}
if (!items) {
try {
setItems(await nutritionApi.listNutritionItems(date))
} catch (e) {
setError(e.message)
return
}
}
setOpen(true)
}
return (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={toggle}>
{open ? 'Lebensmittel ausblenden' : 'Lebensmittel zeigen'}
</button>
{error && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{error}</p>}
{open && Array.isArray(items) && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 13, color: 'var(--text1)' }}>
{items.length === 0 && <li style={{ color: 'var(--text3)' }}>Keine Zeilen für diesen Tag</li>}
{items.map((it) => (
<li key={it.id}>
{it.source_name_raw}
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
{it.food_name_de ? `${it.food_name_de}` : ' · noch nicht zugeordnet'}
</li>
))}
</ul>
)}
</div>
)
}
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'}` : ''}
{e.item_count ? ` · ${e.item_count} Lebensmittel` : ''}
</div>
)}
{e.item_count > 0 ? (
<DayItems date={e.date} />
) : (
<div style={{fontSize:12, color:'var(--text3)', marginTop:6}}>Keine einzelnen Lebensmittelzeilen</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,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={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 [unmappedCount, setUnmappedCount] = useState(0)
const [unmappedTotal, setUnmappedTotal] = useState(0)
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, unmapped] = await Promise.all([
nutritionApi.nutritionCorrelations(),
nutritionApi.nutritionWeekly(16),
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
nutritionApi.getActiveProfile(),
nutritionApi.listUnmappedFoodCount(28).catch(() => ({ count: 0, total: 0 })),
])
setCorr(Array.isArray(corr)?corr:[])
setWeekly(Array.isArray(wkly)?wkly:[])
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
setProf(prof)
setUnmappedCount(Number(unmapped?.count) || 0)
setUnmappedTotal(Number(unmapped?.total) || Number(unmapped?.count) || 0)
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
} catch(e) { console.error('load error:', e) }
finally { setLoad(false) }
}
const refreshUnmappedCount = async () => {
try {
const d = await nutritionApi.listUnmappedFoodCount(28)
setUnmappedCount(Number(d?.count) || 0)
setUnmappedTotal(Number(d?.total) || Number(d?.count) || 0)
} catch { /* Banner bleibt auf letztem Stand */ }
}
useEffect(() => { load(); loadUsage() }, [])
return (
<div className="capture-page">
<h1 className="page-title">Ernährung</h1>
{(unmappedCount > 0 || unmappedTotal > 0) && (
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
{unmappedCount > 0
? `${unmappedCount} Lebensmittel der letzten 4 Wochen noch ohne Zuordnung${unmappedTotal > unmappedCount ? ` · ${unmappedTotal} insgesamt` : ''}.`
: `In den letzten 4 Wochen ist alles zugeordnet. ${unmappedTotal} ältere Namen ohne Zuordnung — nur nötig, wenn du sie wieder isst.`}
{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
Jetzt zuordnen
</button>
</div>
)}
{/* 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{unmappedCount ? ` (${unmappedCount})` : ''}
</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
onMapped={refreshUnmappedCount}
onChanged={refreshUnmappedCount}
/>
)}
{loading && inputTab !== 'map' && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && inputTab !== 'map' && (
<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 && inputTab !== 'map' && (
<>
<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>
)
}