2026-03-16 13:35:11 +01:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 08:37:01 +01:00
|
|
|
|
// ── 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
|
|
|
|
// ── 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)
|
2026-03-21 08:33:03 +01:00
|
|
|
|
const [filter, setFilter] = useState('30') // days to show (7, 30, 90, 'all')
|
feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 08:33:03 +01:00
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
|
|
|
|
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">
|
2026-03-21 08:33:03 +01:00
|
|
|
|
<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>
|
feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
|
|
|
|
{error && (
|
|
|
|
|
|
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-21 08:33:03 +01:00
|
|
|
|
{filteredEntries.map((e, i) => {
|
feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
|
|
|
|
const isEditing = editId === e.id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={e.id || i} style={{
|
2026-03-21 08:33:03 +01:00
|
|
|
|
borderBottom: i < filteredEntries.length - 1 ? '1px solid var(--border)' : 'none',
|
feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
// ── 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 }) {
|
2026-03-21 09:51:20 +01:00
|
|
|
|
// BUG-003 fix: Show all weight data, extrapolate kcal if missing
|
|
|
|
|
|
const filtered = data.filter(d => d.kcal || d.weight)
|
2026-03-16 13:35:11 +01:00
|
|
|
|
if (filtered.length < 3) return (
|
|
|
|
|
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
|
2026-03-21 09:51:20 +01:00
|
|
|
|
Zu wenig Daten für diese Auswertung
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-03-21 09:51:20 +01:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
return (
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={220}>
|
2026-03-21 09:51:20 +01:00
|
|
|
|
<LineChart data={realData} margin={{top:4,right:8,bottom:0,left:-16}}>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
|
|
|
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
2026-03-21 09:51:20 +01:00
|
|
|
|
interval={Math.max(0,Math.floor(realData.length/6)-1)}/>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<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}}
|
2026-03-21 09:51:20 +01:00
|
|
|
|
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}/>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</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() {
|
2026-03-21 08:43:55 +01:00
|
|
|
|
const [inputTab, setInputTab] = useState('entry') // 'entry' or 'import'
|
|
|
|
|
|
const [analysisTab,setAnalysisTab] = useState('data')
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [corrData, setCorr] = useState([])
|
|
|
|
|
|
const [weekly, setWeekly] = useState([])
|
2026-03-21 08:43:55 +01:00
|
|
|
|
const [entries, setEntries]= useState([])
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [profile, setProf] = useState(null)
|
|
|
|
|
|
const [loading, setLoad] = useState(true)
|
|
|
|
|
|
const [hasData, setHasData]= useState(false)
|
2026-03-21 09:50:35 +01:00
|
|
|
|
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
|
setLoad(true)
|
|
|
|
|
|
try {
|
2026-03-21 08:06:01 +01:00
|
|
|
|
const [corr, wkly, ent, prof] = await Promise.all([
|
2026-03-16 13:35:11 +01:00
|
|
|
|
nutritionApi.nutritionCorrelations(),
|
|
|
|
|
|
nutritionApi.nutritionWeekly(16),
|
2026-03-21 08:06:01 +01:00
|
|
|
|
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
|
|
|
|
nutritionApi.getActiveProfile(),
|
2026-03-16 13:35:11 +01:00
|
|
|
|
])
|
|
|
|
|
|
setCorr(Array.isArray(corr)?corr:[])
|
|
|
|
|
|
setWeekly(Array.isArray(wkly)?wkly:[])
|
2026-03-21 08:06:01 +01:00
|
|
|
|
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
|
2026-03-16 13:35:11 +01:00
|
|
|
|
setProf(prof)
|
|
|
|
|
|
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
|
|
|
|
|
} catch(e) { console.error('load error:', e) }
|
|
|
|
|
|
finally { setLoad(false) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-05 10:21:06 +02:00
|
|
|
|
<div className="capture-page">
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<h1 className="page-title">Ernährung</h1>
|
|
|
|
|
|
|
2026-03-21 08:43:55 +01:00
|
|
|
|
{/* 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' && (
|
|
|
|
|
|
<>
|
2026-03-21 09:50:35 +01:00
|
|
|
|
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
|
|
|
|
|
|
<ImportHistory key={importHistoryKey}/>
|
2026-03-21 08:43:55 +01:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && !hasData && (
|
|
|
|
|
|
<div className="empty-state">
|
|
|
|
|
|
<h3>Noch keine Ernährungsdaten</h3>
|
2026-03-21 08:43:55 +01:00
|
|
|
|
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-21 08:43:55 +01:00
|
|
|
|
{/* Analysis Section */}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{!loading && hasData && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<OverviewCards data={corrData}/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
|
2026-03-21 08:43:55 +01:00
|
|
|
|
<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>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-21 08:43:55 +01:00
|
|
|
|
{analysisTab==='data' && <DataTab entries={entries} onUpdate={load}/>}
|
2026-03-21 08:06:01 +01:00
|
|
|
|
|
2026-03-21 08:43:55 +01:00
|
|
|
|
{analysisTab==='overview' && (
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-21 08:43:55 +01:00
|
|
|
|
{analysisTab==='weight' && (
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-21 08:43:55 +01:00
|
|
|
|
{analysisTab==='protein' && (
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-21 08:43:55 +01:00
|
|
|
|
{analysisTab==='balance' && (
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|