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>
912 lines
38 KiB
JavaScript
912 lines
38 KiB
JavaScript
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; 13.03.2026 21:54;50 g Hähnchen;..."
|
||
value={pasteText}
|
||
onChange={e=>setPasteText(e.target.value)}
|
||
/>
|
||
<button className="btn btn-primary btn-full" style={{marginTop:8}}
|
||
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}>
|
||
{status==='loading'
|
||
? <><div className="spinner" style={{width:14,height:14}}/> Importiere…</>
|
||
: <><Upload size={15}/> CSV-Text importieren</>}
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{status==='loading' && (
|
||
<div style={{marginTop:10,display:'flex',alignItems:'center',gap:8,fontSize:13,color:'var(--text2)'}}>
|
||
<div className="spinner" style={{width:16,height:16}}/> Importiere…
|
||
</div>
|
||
)}
|
||
{error && (
|
||
<div style={{marginTop:8,padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30'}}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
{status && status !== 'loading' && (
|
||
<div style={{marginTop:8,padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)'}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}>
|
||
<CheckCircle size={15}/><strong>Import erfolgreich</strong>
|
||
</div>
|
||
<div>{status.days_imported} Tage importiert · {status.rows_parsed} Einträge verarbeitet</div>
|
||
{status.date_range?.from && (
|
||
<div style={{fontSize:11,marginTop:2}}>
|
||
{dayjs(status.date_range.from).format('DD.MM.YYYY')} – {dayjs(status.date_range.to).format('DD.MM.YYYY')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Overview Cards ────────────────────────────────────────────────────────────
|
||
function OverviewCards({ data }) {
|
||
if (!data.length) return null
|
||
const last7 = data.filter(d=>d.kcal).slice(-7)
|
||
if (!last7.length) return null
|
||
const avg = key => Math.round(last7.map(d=>d[key]||0).reduce((a,b)=>a+b,0)/last7.length)
|
||
const kcal = avg('kcal'), prot = avg('protein_g'), fat = avg('fat_g'), carbs = avg('carbs_g')
|
||
const total_g = prot + fat + carbs
|
||
return (
|
||
<div className="card section-gap">
|
||
<div className="card-title">Ø letzte 7 Tage</div>
|
||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8}}>
|
||
{[
|
||
['🔥 Kalorien', kcal, 'kcal', '#EF9F27'],
|
||
['🥩 Protein', prot, 'g', '#1D9E75'],
|
||
['🫙 Fett', fat, 'g', '#378ADD'],
|
||
['🍞 Kohlenhydrate', carbs, 'g', '#D4537E'],
|
||
].map(([label, val, unit, color]) => (
|
||
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
|
||
<div style={{fontSize:20,fontWeight:700,color}}>{val}<span style={{fontSize:12,color:'var(--text3)',marginLeft:2}}>{unit}</span></div>
|
||
<div style={{fontSize:11,color:'var(--text3)'}}>{label}</div>
|
||
{unit==='g' && total_g>0 && <div style={{fontSize:10,color:'var(--text3)'}}>{Math.round(val/total_g*100)}% der Makros</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{marginTop:8,padding:'6px 10px',background:'var(--surface2)',borderRadius:8,fontSize:12,color:'var(--text3)'}}>
|
||
<Info size={11} style={{marginRight:4,verticalAlign:'middle'}}/>
|
||
Protein-Ziel: 1,6–2,2 g/kg Körpergewicht für Muskelaufbau
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Chart: Kalorien vs Gewicht ────────────────────────────────────────────────
|
||
function CaloriesVsWeight({ data }) {
|
||
// 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 ~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>
|
||
)
|
||
}
|