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

481 lines
19 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
ComposedChart, ReferenceArea,
} from 'recharts'
import { api } from '../utils/api'
import { MACRO_CHART, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
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>
)
}
/** Wöchentliche Makro-Verteilung (E3) — für Verlauf neben Donut nutzbar. */
export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error }) {
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)
}
if (error) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{error}</div>
)
}
if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') {
const msg = macroWeeklyData?.metadata?.message || 'Nicht genug Daten für Wochen-Analyse (min. 7 Tage)'
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</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 (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Anteil der Kalorien aus jedem Makronährstoff pro Kalenderwoche (100&nbsp;% gestapelt). Gut vergleichbar mit der
Donut-Übersicht links.
</div>
<div className="nutrition-macro-pair__chart-wrap">
<ResponsiveContainer width="100%" height={NUTRITION_MACRO_CHART_BLOCK_PX}>
<BarChart data={chartData} margin={{ top: 8, right: 4, bottom: 4, left: -18 }} barCategoryGap="18%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<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]} ticks={[0, 25, 50, 75, 100]} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${v}%`, name]}
/>
<Legend wrapperStyle={{ fontSize: 10, paddingTop: 8 }} />
<Bar dataKey="protein" stackId="a" fill={MACRO_CHART.protein} name="Protein %" radius={[0, 0, 0, 0]} />
<Bar dataKey="fat" stackId="a" fill={MACRO_CHART.fat} name="Fett %" radius={[0, 0, 0, 0]} />
<Bar dataKey="carbs" stackId="a" fill={MACRO_CHART.carbs} name="KH %" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.5 }}>
Ø Verteilung: P {meta.avg_protein_pct}% · KH {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · Variabilität (CV): P{' '}
{meta.protein_cv}% · KH {meta.carbs_cv}% · F {meta.fat_cv}%
</div>
</>
)
}
/**
* Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird.
*/
export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true }) {
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, showWeeklyMacroDistribution])
const loadCharts = async () => {
const tasks = [
loadEnergyBalance(),
loadProteinAdequacy(),
loadAdherence(),
loadWarning(),
]
if (showWeeklyMacroDistribution) {
tasks.splice(2, 0, loadMacroWeekly())
}
await Promise.all(tasks)
}
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 — klare Farben (kein hellgraues Gewirr)
const renderEnergyBalance = () => {
if (!energyData || energyData.metadata?.confidence === 'insufficient') {
const msg = energyData?.metadata?.message || 'Nicht genug Ernährungsdaten für die Energiebilanz.'
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</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 (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Tägliche Aufnahme, gleitende Mittel und geschätzter TDEE — Linien sind farblich getrennt (Legende unten).
</div>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={chartData} margin={{ top: 6, right: 10, bottom: 4, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<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, paddingTop: 6 }} />
<Line type="monotone" dataKey="täglich" stroke="#64748B" strokeWidth={2} dot={{ r: 2 }} name="Täglich kcal" />
<Line type="monotone" dataKey="avg14d" stroke="#6366F1" strokeWidth={2.5} dot={false} name="Ø 14 Tage" />
<Line type="monotone" dataKey="avg7d" stroke="#10B981" strokeWidth={3} dot={false} name="Ø 7 Tage" />
<Line type="monotone" dataKey="tdee" stroke="#EA580C" strokeWidth={2.5} strokeDasharray="10 5" dot={false} name="TDEE (Referenz)" />
</LineChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, textAlign: 'center', lineHeight: 1.5 }}>
<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 — Zielzone als Fläche, Linien klar von E1 abgrenzbar
const renderProteinAdequacy = () => {
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
const msg = proteinData?.metadata?.message || 'Nicht genug Protein-Daten für dieses Diagramm.'
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</div>
)
}
const tl = proteinData.metadata.target_low
const th = proteinData.metadata.target_high
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],
}))
return (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Grüne Zone = empfohlenes Protein-Ziel (g/Tag). Tägliche Werte und Mittel — andere Farben als Energiebilanz oben.
</div>
<ResponsiveContainer width="100%" height={250}>
<ComposedChart data={chartData} margin={{ top: 6, right: 10, bottom: 4, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<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 }}
/>
{tl != null && th != null && (
<ReferenceArea y1={tl} y2={th} fill="rgba(16, 185, 129, 0.14)" stroke="#10B981" strokeOpacity={0.35} />
)}
<Legend wrapperStyle={{ fontSize: 10, paddingTop: 6 }} />
<Line type="monotone" dataKey="avg28d" stroke="#7C3AED" strokeWidth={2.5} dot={false} name="Ø 28 Tage" />
<Line type="monotone" dataKey="avg7d" stroke="#059669" strokeWidth={3} dot={false} name="Ø 7 Tage" />
<Line type="monotone" dataKey="täglich" stroke="#0284C7" strokeWidth={2} dot={{ r: 2 }} name="Täglich g" />
</ComposedChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.5 }}>
Ziel {tl}–{th} g/Tag · {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich (
{proteinData.metadata.target_compliance_pct}%)
</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" loading={loading.energy} error={errors.energy}>
{renderEnergyBalance()}
</ChartCard>
<ChartCard title="📊 Protein (Adequacy)" loading={loading.protein} error={errors.protein}>
{renderProteinAdequacy()}
</ChartCard>
{showWeeklyMacroDistribution && (
<ChartCard title="📊 Wöchentliche Makro-Verteilung" loading={loading.macro} error={errors.macro}>
<WeeklyMacroDistributionPanel macroWeeklyData={macroWeeklyData} loading={false} error={null} />
</ChartCard>
)}
{!loading.adherence && !errors.adherence && renderAdherence()}
{!loading.warning && !errors.warning && renderWarning()}
</div>
)
}