mitai-jinkendo/frontend/src/components/NutritionCharts.jsx
Lars d7304c1a44
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: implement energy availability warning and enhance nutrition visualization
- Added `get_energy_availability_warning_payload` function to assess energy availability and provide contextual warnings based on multiple health indicators.
- Integrated energy availability KPI tile into the nutrition history visualization, enhancing user insights on energy balance.
- Updated frontend components to conditionally display the energy availability warning, improving user experience and data interpretation.
- Refactored existing logic in `charts.py` to utilize the new energy availability functionality, streamlining data handling.
2026-04-19 17:43:29 +02:00

488 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (E1E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z.B. neben Donut) gerendert wird.
*/
export default function NutritionCharts({
days = 28,
showWeeklyMacroDistribution = true,
/** Verlauf: E5-Kachel liegt in nutrition-history-viz KPIs — doppelte Karte ausblenden */
hideEnergyAvailabilityCard = false,
}) {
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, hideEnergyAvailabilityCard])
const loadCharts = async () => {
const tasks = [
loadEnergyBalance(),
loadProteinAdequacy(),
loadAdherence(),
]
if (!hideEnergyAvailabilityCard) {
tasks.push(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()}
{!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
</div>
)
}