mitai-jinkendo/frontend/src/components/NutritionCharts.jsx

285 lines
10 KiB
React
Raw Normal View History

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>
)
}