285 lines
10 KiB
React
285 lines
10 KiB
React
|
|
import { useState, useEffect } from 'react'
|
||
|
|
import {
|
||
|
|
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
|
||
|
|
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||
|
|
ReferenceLine
|
||
|
|
} 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>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Nutrition Charts Component (E1-E5)
|
||
|
|
*
|
||
|
|
* Displays 4 nutrition chart endpoints:
|
||
|
|
* - Energy Balance Timeline (E1)
|
||
|
|
* - Macro Distribution (E2)
|
||
|
|
* - Protein Adequacy (E3)
|
||
|
|
* - Nutrition Consistency (E5)
|
||
|
|
*/
|
||
|
|
export default function NutritionCharts({ days = 28 }) {
|
||
|
|
const [energyData, setEnergyData] = useState(null)
|
||
|
|
const [macroData, setMacroData] = useState(null)
|
||
|
|
const [proteinData, setProteinData] = useState(null)
|
||
|
|
const [consistencyData, setConsistencyData] = useState(null)
|
||
|
|
|
||
|
|
const [loading, setLoading] = useState({})
|
||
|
|
const [errors, setErrors] = useState({})
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadCharts()
|
||
|
|
}, [days])
|
||
|
|
|
||
|
|
const loadCharts = async () => {
|
||
|
|
// Load all 4 charts in parallel
|
||
|
|
await Promise.all([
|
||
|
|
loadEnergyBalance(),
|
||
|
|
loadMacroDistribution(),
|
||
|
|
loadProteinAdequacy(),
|
||
|
|
loadConsistency()
|
||
|
|
])
|
||
|
|
}
|
||
|
|
|
||
|
|
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 loadMacroDistribution = async () => {
|
||
|
|
setLoading(l => ({...l, macro: true}))
|
||
|
|
setErrors(e => ({...e, macro: null}))
|
||
|
|
try {
|
||
|
|
const data = await api.getMacroDistributionChart(days)
|
||
|
|
setMacroData(data)
|
||
|
|
} catch (err) {
|
||
|
|
setErrors(e => ({...e, macro: err.message}))
|
||
|
|
} finally {
|
||
|
|
setLoading(l => ({...l, macro: 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 loadConsistency = async () => {
|
||
|
|
setLoading(l => ({...l, consistency: true}))
|
||
|
|
setErrors(e => ({...e, consistency: null}))
|
||
|
|
try {
|
||
|
|
const data = await api.getNutritionConsistencyChart(days)
|
||
|
|
setConsistencyData(data)
|
||
|
|
} catch (err) {
|
||
|
|
setErrors(e => ({...e, consistency: err.message}))
|
||
|
|
} finally {
|
||
|
|
setLoading(l => ({...l, consistency: false}))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// E1: Energy Balance Timeline
|
||
|
|
const renderEnergyBalance = () => {
|
||
|
|
if (!energyData || energyData.metadata?.confidence === 'insufficient') {
|
||
|
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||
|
|
Nicht genug Ernährungsdaten
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
|
||
|
|
const chartData = energyData.data.labels.map((label, i) => ({
|
||
|
|
date: fmtDate(label),
|
||
|
|
kcal: energyData.data.datasets[0]?.data[i],
|
||
|
|
tdee: energyData.data.datasets[1]?.data[i]
|
||
|
|
}))
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<ResponsiveContainer width="100%" height={200}>
|
||
|
|
<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}}/>
|
||
|
|
<Line type="monotone" dataKey="kcal" stroke="#1D9E75" strokeWidth={2} name="Kalorien" dot={{r:2}}/>
|
||
|
|
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="TDEE (geschätzt)" dot={false}/>
|
||
|
|
</LineChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||
|
|
Ø {energyData.metadata.avg_kcal} kcal/Tag · {energyData.metadata.data_points} Einträge
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// E2: Macro Distribution (Pie)
|
||
|
|
const renderMacroDistribution = () => {
|
||
|
|
if (!macroData || macroData.metadata?.confidence === 'insufficient') {
|
||
|
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||
|
|
Nicht genug Makronährstoff-Daten
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
|
||
|
|
const chartData = macroData.data.labels.map((label, i) => ({
|
||
|
|
name: label,
|
||
|
|
value: macroData.data.datasets[0]?.data[i],
|
||
|
|
color: macroData.data.datasets[0]?.backgroundColor[i]
|
||
|
|
}))
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<ResponsiveContainer width="100%" height={200}>
|
||
|
|
<PieChart>
|
||
|
|
<Pie
|
||
|
|
data={chartData}
|
||
|
|
cx="50%"
|
||
|
|
cy="50%"
|
||
|
|
labelLine={false}
|
||
|
|
label={({name, value}) => `${name}: ${value}%`}
|
||
|
|
outerRadius={70}
|
||
|
|
dataKey="value"
|
||
|
|
>
|
||
|
|
{chartData.map((entry, index) => (
|
||
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||
|
|
))}
|
||
|
|
</Pie>
|
||
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||
|
|
</PieChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||
|
|
P: {macroData.metadata.protein_g}g · C: {macroData.metadata.carbs_g}g · F: {macroData.metadata.fat_g}g
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// E3: Protein Adequacy Timeline
|
||
|
|
const renderProteinAdequacy = () => {
|
||
|
|
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
|
||
|
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||
|
|
Nicht genug Protein-Daten
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
|
||
|
|
const chartData = proteinData.data.labels.map((label, i) => ({
|
||
|
|
date: fmtDate(label),
|
||
|
|
protein: proteinData.data.datasets[0]?.data[i],
|
||
|
|
targetLow: proteinData.data.datasets[1]?.data[i],
|
||
|
|
targetHigh: proteinData.data.datasets[2]?.data[i]
|
||
|
|
}))
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<ResponsiveContainer width="100%" height={200}>
|
||
|
|
<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}}/>
|
||
|
|
<Line type="monotone" dataKey="targetLow" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="Ziel Min" dot={false}/>
|
||
|
|
<Line type="monotone" dataKey="targetHigh" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="Ziel Max" dot={false}/>
|
||
|
|
<Line type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} name="Protein (g)" dot={{r:2}}/>
|
||
|
|
</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>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// E5: Nutrition Consistency (Bar)
|
||
|
|
const renderConsistency = () => {
|
||
|
|
if (!consistencyData || consistencyData.metadata?.confidence === 'insufficient') {
|
||
|
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||
|
|
Nicht genug Daten für Konsistenz-Analyse
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
|
||
|
|
const chartData = consistencyData.data.labels.map((label, i) => ({
|
||
|
|
name: label,
|
||
|
|
score: consistencyData.data.datasets[0]?.data[i],
|
||
|
|
color: consistencyData.data.datasets[0]?.backgroundColor[i]
|
||
|
|
}))
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<ResponsiveContainer width="100%" height={200}>
|
||
|
|
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||
|
|
<XAxis dataKey="name" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||
|
|
interval={0} angle={-45} textAnchor="end" height={60}/>
|
||
|
|
<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}}/>
|
||
|
|
<Bar dataKey="score" name="Score">
|
||
|
|
{chartData.map((entry, index) => (
|
||
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||
|
|
))}
|
||
|
|
</Bar>
|
||
|
|
</BarChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||
|
|
Gesamt-Score: {consistencyData.metadata.consistency_score}/100
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<ChartCard title="📊 Energiebilanz" loading={loading.energy} error={errors.energy}>
|
||
|
|
{renderEnergyBalance()}
|
||
|
|
</ChartCard>
|
||
|
|
|
||
|
|
<ChartCard title="📊 Makronährstoff-Verteilung" loading={loading.macro} error={errors.macro}>
|
||
|
|
{renderMacroDistribution()}
|
||
|
|
</ChartCard>
|
||
|
|
|
||
|
|
<ChartCard title="📊 Protein-Adequacy" loading={loading.protein} error={errors.protein}>
|
||
|
|
{renderProteinAdequacy()}
|
||
|
|
</ChartCard>
|
||
|
|
|
||
|
|
<ChartCard title="📊 Ernährungs-Konsistenz" loading={loading.consistency} error={errors.consistency}>
|
||
|
|
{renderConsistency()}
|
||
|
|
</ChartCard>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|