Backend Enhancements: - E1: Energy Balance mit 7d/14d rolling averages + balance calculation - E2: Protein Adequacy mit 7d/28d rolling averages - E3: Weekly Macro Distribution (100% stacked bars, ISO weeks, CV) - E4: Nutrition Adherence Score (0-100, goal-aware weighting) - E5: Energy Availability Warning (multi-trigger heuristic system) Frontend Refactoring: - NutritionCharts.jsx komplett überarbeitet - ScoreCard component für E4 (circular score display) - WarningCard component für E5 (ampel system) - Alle Charts zeigen jetzt Trends statt nur Rohdaten - Legend + enhanced metadata display API Updates: - getWeeklyMacroDistributionChart (weeks parameter) - getNutritionAdherenceScore - getEnergyAvailabilityWarning - Removed old getMacroDistributionChart (pie) Konzept-Compliance: - Zeitfenster: 7d, 28d, 90d selectors - Deutlich höhere Aussagekraft durch rolling averages - Goal-mode-abhängige Score-Gewichtung - Cross-domain warning system (nutrition × recovery × body) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
434 lines
16 KiB
JavaScript
434 lines
16 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import {
|
|
LineChart, Line, BarChart, Bar,
|
|
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend
|
|
} from 'recharts'
|
|
import { api } from '../utils/api'
|
|
import dayjs from 'dayjs'
|
|
|
|
const fmtDate = d => dayjs(d).format('DD.MM')
|
|
|
|
function ChartCard({ title, loading, error, children }) {
|
|
return (
|
|
<div className="card" style={{marginBottom:12}}>
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
|
{title}
|
|
</div>
|
|
{loading && (
|
|
<div style={{display:'flex',justifyContent:'center',padding:40}}>
|
|
<div className="spinner" style={{width:32,height:32}}/>
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{!loading && !error && children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ScoreCard({ title, score, components, goal_mode, recommendation }) {
|
|
const scoreColor = score >= 80 ? '#1D9E75' : score >= 60 ? '#F59E0B' : '#EF4444'
|
|
|
|
return (
|
|
<div className="card" style={{marginBottom:12}}>
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:12}}>
|
|
{title}
|
|
</div>
|
|
|
|
{/* Score Circle */}
|
|
<div style={{display:'flex',alignItems:'center',justifyContent:'center',marginBottom:16}}>
|
|
<div style={{
|
|
width:120,height:120,borderRadius:'50%',
|
|
border:`8px solid ${scoreColor}`,
|
|
display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center'
|
|
}}>
|
|
<div style={{fontSize:32,fontWeight:700,color:scoreColor}}>{score}</div>
|
|
<div style={{fontSize:10,color:'var(--text3)'}}>/ 100</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Components Breakdown */}
|
|
<div style={{fontSize:11,marginBottom:12}}>
|
|
{Object.entries(components).map(([key, value]) => {
|
|
const barColor = value >= 80 ? '#1D9E75' : value >= 60 ? '#F59E0B' : '#EF4444'
|
|
const label = {
|
|
'calorie_adherence': 'Kalorien-Adhärenz',
|
|
'protein_adherence': 'Protein-Adhärenz',
|
|
'intake_consistency': 'Konsistenz',
|
|
'food_quality': 'Lebensmittelqualität'
|
|
}[key] || key
|
|
|
|
return (
|
|
<div key={key} style={{marginBottom:8}}>
|
|
<div style={{display:'flex',justifyContent:'space-between',marginBottom:2}}>
|
|
<span style={{color:'var(--text2)',fontSize:10}}>{label}</span>
|
|
<span style={{color:'var(--text1)',fontSize:10,fontWeight:600}}>{value}</span>
|
|
</div>
|
|
<div style={{height:4,background:'var(--surface2)',borderRadius:2,overflow:'hidden'}}>
|
|
<div style={{height:'100%',width:`${value}%`,background:barColor,transition:'width 0.3s'}}/>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Recommendation */}
|
|
<div style={{
|
|
padding:8,background:'var(--surface2)',borderRadius:6,
|
|
fontSize:10,color:'var(--text2)',marginBottom:8
|
|
}}>
|
|
💡 {recommendation}
|
|
</div>
|
|
|
|
{/* Goal Mode */}
|
|
<div style={{fontSize:9,color:'var(--text3)',textAlign:'center'}}>
|
|
Optimiert für: {goal_mode || 'health'}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function WarningCard({ title, warning_level, triggers, message }) {
|
|
const levelConfig = {
|
|
'warning': { icon: '⚠️', color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
|
'caution': { icon: '⚡', color: '#F59E0B', bg: 'rgba(245, 158, 11, 0.1)' },
|
|
'none': { icon: '✅', color: '#1D9E75', bg: 'rgba(29, 158, 117, 0.1)' }
|
|
}[warning_level] || levelConfig['none']
|
|
|
|
return (
|
|
<div className="card" style={{marginBottom:12}}>
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:12}}>
|
|
{title}
|
|
</div>
|
|
|
|
{/* Status Badge */}
|
|
<div style={{
|
|
padding:16,background:levelConfig.bg,borderRadius:8,
|
|
borderLeft:`4px solid ${levelConfig.color}`,marginBottom:12
|
|
}}>
|
|
<div style={{fontSize:14,fontWeight:600,color:levelConfig.color,marginBottom:4}}>
|
|
{levelConfig.icon} {message}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Triggers List */}
|
|
{triggers && triggers.length > 0 && (
|
|
<div style={{marginTop:12}}>
|
|
<div style={{fontSize:10,fontWeight:600,color:'var(--text3)',marginBottom:6}}>
|
|
Auffällige Indikatoren:
|
|
</div>
|
|
<ul style={{margin:0,paddingLeft:20,fontSize:10,color:'var(--text2)'}}>
|
|
{triggers.map((t, i) => (
|
|
<li key={i} style={{marginBottom:4}}>{t}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{fontSize:9,color:'var(--text3)',marginTop:12,fontStyle:'italic'}}>
|
|
Heuristische Einschätzung, keine medizinische Diagnose
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Nutrition Charts Component (E1-E5) - Konzept-konform v2.0
|
|
*
|
|
* E1: Energy Balance (mit 7d/14d Durchschnitten)
|
|
* E2: Protein Adequacy (mit 7d/28d Durchschnitten)
|
|
* E3: Weekly Macro Distribution (100% gestapelte Balken)
|
|
* E4: Nutrition Adherence Score (0-100, goal-aware)
|
|
* E5: Energy Availability Warning (Ampel-System)
|
|
*/
|
|
export default function NutritionCharts({ days = 28 }) {
|
|
const [energyData, setEnergyData] = useState(null)
|
|
const [proteinData, setProteinData] = useState(null)
|
|
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
|
|
const [adherenceData, setAdherenceData] = useState(null)
|
|
const [warningData, setWarningData] = useState(null)
|
|
|
|
const [loading, setLoading] = useState({})
|
|
const [errors, setErrors] = useState({})
|
|
|
|
// Weeks for macro distribution (proportional to days selected)
|
|
const weeks = Math.max(4, Math.min(52, Math.ceil(days / 7)))
|
|
|
|
useEffect(() => {
|
|
loadCharts()
|
|
}, [days])
|
|
|
|
const loadCharts = async () => {
|
|
await Promise.all([
|
|
loadEnergyBalance(),
|
|
loadProteinAdequacy(),
|
|
loadMacroWeekly(),
|
|
loadAdherence(),
|
|
loadWarning()
|
|
])
|
|
}
|
|
|
|
const loadEnergyBalance = async () => {
|
|
setLoading(l => ({...l, energy: true}))
|
|
setErrors(e => ({...e, energy: null}))
|
|
try {
|
|
const data = await api.getEnergyBalanceChart(days)
|
|
setEnergyData(data)
|
|
} catch (err) {
|
|
setErrors(e => ({...e, energy: err.message}))
|
|
} finally {
|
|
setLoading(l => ({...l, energy: false}))
|
|
}
|
|
}
|
|
|
|
const loadProteinAdequacy = async () => {
|
|
setLoading(l => ({...l, protein: true}))
|
|
setErrors(e => ({...e, protein: null}))
|
|
try {
|
|
const data = await api.getProteinAdequacyChart(days)
|
|
setProteinData(data)
|
|
} catch (err) {
|
|
setErrors(e => ({...e, protein: err.message}))
|
|
} finally {
|
|
setLoading(l => ({...l, protein: false}))
|
|
}
|
|
}
|
|
|
|
const loadMacroWeekly = async () => {
|
|
setLoading(l => ({...l, macro: true}))
|
|
setErrors(e => ({...e, macro: null}))
|
|
try {
|
|
const data = await api.getWeeklyMacroDistributionChart(weeks)
|
|
setMacroWeeklyData(data)
|
|
} catch (err) {
|
|
setErrors(e => ({...e, macro: err.message}))
|
|
} finally {
|
|
setLoading(l => ({...l, macro: false}))
|
|
}
|
|
}
|
|
|
|
const loadAdherence = async () => {
|
|
setLoading(l => ({...l, adherence: true}))
|
|
setErrors(e => ({...e, adherence: null}))
|
|
try {
|
|
const data = await api.getNutritionAdherenceScore(days)
|
|
setAdherenceData(data)
|
|
} catch (err) {
|
|
setErrors(e => ({...e, adherence: err.message}))
|
|
} finally {
|
|
setLoading(l => ({...l, adherence: false}))
|
|
}
|
|
}
|
|
|
|
const loadWarning = async () => {
|
|
setLoading(l => ({...l, warning: true}))
|
|
setErrors(e => ({...e, warning: null}))
|
|
try {
|
|
const data = await api.getEnergyAvailabilityWarning(Math.min(days, 28))
|
|
setWarningData(data)
|
|
} catch (err) {
|
|
setErrors(e => ({...e, warning: err.message}))
|
|
} finally {
|
|
setLoading(l => ({...l, warning: false}))
|
|
}
|
|
}
|
|
|
|
// E1: Energy Balance Timeline (mit 7d/14d Durchschnitten)
|
|
const renderEnergyBalance = () => {
|
|
if (!energyData || energyData.metadata?.confidence === 'insufficient') {
|
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
|
Nicht genug Ernährungsdaten (min. 7 Tage)
|
|
</div>
|
|
}
|
|
|
|
const chartData = energyData.data.labels.map((label, i) => ({
|
|
date: fmtDate(label),
|
|
täglich: energyData.data.datasets[0]?.data[i],
|
|
avg7d: energyData.data.datasets[1]?.data[i],
|
|
avg14d: energyData.data.datasets[2]?.data[i],
|
|
tdee: energyData.data.datasets[3]?.data[i]
|
|
}))
|
|
|
|
const balance = energyData.metadata?.energy_balance || 0
|
|
const balanceColor = balance < -200 ? '#EF4444' : balance > 200 ? '#F59E0B' : '#1D9E75'
|
|
|
|
return (
|
|
<>
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
<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 tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
|
<Legend wrapperStyle={{fontSize:10}}/>
|
|
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/>
|
|
<Line type="monotone" dataKey="avg7d" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="Ø 7d"/>
|
|
<Line type="monotone" dataKey="avg14d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 14d"/>
|
|
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TDEE"/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
<div style={{marginTop:8,fontSize:10,textAlign:'center'}}>
|
|
<span style={{color:'var(--text3)'}}>
|
|
Ø {energyData.metadata.avg_kcal} kcal/Tag ·
|
|
</span>
|
|
<span style={{color:balanceColor,fontWeight:600,marginLeft:4}}>
|
|
Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag
|
|
</span>
|
|
<span style={{color:'var(--text3)',marginLeft:8}}>
|
|
· {energyData.metadata.data_points} Tage
|
|
</span>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten)
|
|
const renderProteinAdequacy = () => {
|
|
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
|
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
|
Nicht genug Protein-Daten (min. 7 Tage)
|
|
</div>
|
|
}
|
|
|
|
const chartData = proteinData.data.labels.map((label, i) => ({
|
|
date: fmtDate(label),
|
|
täglich: proteinData.data.datasets[0]?.data[i],
|
|
avg7d: proteinData.data.datasets[1]?.data[i],
|
|
avg28d: proteinData.data.datasets[2]?.data[i],
|
|
targetLow: proteinData.data.datasets[3]?.data[i],
|
|
targetHigh: proteinData.data.datasets[4]?.data[i]
|
|
}))
|
|
|
|
return (
|
|
<>
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
<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 tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
|
<Legend wrapperStyle={{fontSize:10}}/>
|
|
<Line type="monotone" dataKey="targetLow" stroke="#888" strokeWidth={1} strokeDasharray="5 5" dot={false} name="Ziel Min"/>
|
|
<Line type="monotone" dataKey="targetHigh" stroke="#888" strokeWidth={1} strokeDasharray="5 5" dot={false} name="Ziel Max"/>
|
|
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/>
|
|
<Line type="monotone" dataKey="avg7d" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="Ø 7d"/>
|
|
<Line type="monotone" dataKey="avg28d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 28d"/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
|
{proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ({proteinData.metadata.target_compliance_pct}%)
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// E3: Weekly Macro Distribution (100% gestapelte Balken)
|
|
const renderMacroWeekly = () => {
|
|
if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') {
|
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
|
Nicht genug Daten für Wochen-Analyse (min. 7 Tage)
|
|
</div>
|
|
}
|
|
|
|
const chartData = macroWeeklyData.data.labels.map((label, i) => ({
|
|
week: label,
|
|
protein: macroWeeklyData.data.datasets[0]?.data[i],
|
|
carbs: macroWeeklyData.data.datasets[1]?.data[i],
|
|
fat: macroWeeklyData.data.datasets[2]?.data[i]
|
|
}))
|
|
|
|
const meta = macroWeeklyData.metadata
|
|
|
|
return (
|
|
<>
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
<XAxis dataKey="week" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
|
interval={Math.max(0,Math.floor(chartData.length/8)-1)}/>
|
|
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={[0,100]}/>
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
|
<Legend wrapperStyle={{fontSize:10}}/>
|
|
<Bar dataKey="protein" stackId="a" fill="#1D9E75" name="Protein %"/>
|
|
<Bar dataKey="carbs" stackId="a" fill="#F59E0B" name="Kohlenhydrate %"/>
|
|
<Bar dataKey="fat" stackId="a" fill="#EF4444" name="Fett %"/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
|
Ø Verteilung: P {meta.avg_protein_pct}% · C {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% ·
|
|
Konsistenz (CV): P {meta.protein_cv}% · C {meta.carbs_cv}% · F {meta.fat_cv}%
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// E4: Nutrition Adherence Score
|
|
const renderAdherence = () => {
|
|
if (!adherenceData || adherenceData.metadata?.confidence === 'insufficient') {
|
|
return (
|
|
<ChartCard title="🎯 Ernährungs-Adhärenz Score" loading={loading.adherence} error={errors.adherence}>
|
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
|
Nicht genug Daten (min. 7 Tage)
|
|
</div>
|
|
</ChartCard>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ScoreCard
|
|
title="🎯 Ernährungs-Adhärenz Score"
|
|
score={adherenceData.score}
|
|
components={adherenceData.components}
|
|
goal_mode={adherenceData.goal_mode}
|
|
recommendation={adherenceData.recommendation}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// E5: Energy Availability Warning
|
|
const renderWarning = () => {
|
|
if (!warningData) {
|
|
return (
|
|
<ChartCard title="⚡ Energieverfügbarkeit" loading={loading.warning} error={errors.warning}>
|
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
|
Keine Daten verfügbar
|
|
</div>
|
|
</ChartCard>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<WarningCard
|
|
title="⚡ Energieverfügbarkeit"
|
|
warning_level={warningData.warning_level}
|
|
triggers={warningData.triggers}
|
|
message={warningData.message}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<ChartCard title="📊 Energiebilanz (E1)" loading={loading.energy} error={errors.energy}>
|
|
{renderEnergyBalance()}
|
|
</ChartCard>
|
|
|
|
<ChartCard title="📊 Protein-Adequacy (E2)" loading={loading.protein} error={errors.protein}>
|
|
{renderProteinAdequacy()}
|
|
</ChartCard>
|
|
|
|
<ChartCard title="📊 Wöchentliche Makro-Verteilung (E3)" loading={loading.macro} error={errors.macro}>
|
|
{renderMacroWeekly()}
|
|
</ChartCard>
|
|
|
|
{!loading.adherence && !errors.adherence && renderAdherence()}
|
|
{!loading.warning && !errors.warning && renderWarning()}
|
|
</div>
|
|
)
|
|
}
|