Ernährungsasuwertungen #94

Merged
Lars merged 7 commits from develop into main 2026-04-19 21:02:28 +02:00
3 changed files with 475 additions and 286 deletions
Showing only changes of commit a8eafa8ba4 - Show all commits

View File

@ -350,6 +350,24 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
font-style: italic; font-style: italic;
} }
/* Verlauf Ernährung: Donut (Ø-Quote) + wöchentliche Makro-Verteilung (E3) */
.nutrition-macro-pair {
display: grid;
gap: 12px;
margin-bottom: 12px;
align-items: stretch;
}
@media (min-width: 780px) {
.nutrition-macro-pair {
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.25fr);
}
}
.nutrition-macro-pair__weekly {
min-width: 0;
}
.history-page__title { .history-page__title {
margin-bottom: 12px; margin-bottom: 12px;
} }

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { import {
LineChart, Line, BarChart, Bar, LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
ComposedChart, ReferenceArea,
} from 'recharts' } from 'recharts'
import { api } from '../utils/api' import { api } from '../utils/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -135,16 +136,74 @@ function WarningCard({ title, warning_level, triggers, message }) {
) )
} }
/** 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>
<ResponsiveContainer width="100%" height={280}>
<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="#059669" name="Protein %" radius={[0, 0, 0, 0]} />
<Bar dataKey="carbs" stackId="a" fill="#EA580C" name="KH %" radius={[0, 0, 0, 0]} />
<Bar dataKey="fat" stackId="a" fill="#B91C1C" name="Fett %" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<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 Component (E1-E5) - Konzept-konform v2.0 * Nutrition Charts (E1E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z.B. neben Donut) gerendert wird.
*
* 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 }) { export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true }) {
const [energyData, setEnergyData] = useState(null) const [energyData, setEnergyData] = useState(null)
const [proteinData, setProteinData] = useState(null) const [proteinData, setProteinData] = useState(null)
const [macroWeeklyData, setMacroWeeklyData] = useState(null) const [macroWeeklyData, setMacroWeeklyData] = useState(null)
@ -159,16 +218,19 @@ export default function NutritionCharts({ days = 28 }) {
useEffect(() => { useEffect(() => {
loadCharts() loadCharts()
}, [days]) }, [days, showWeeklyMacroDistribution])
const loadCharts = async () => { const loadCharts = async () => {
await Promise.all([ const tasks = [
loadEnergyBalance(), loadEnergyBalance(),
loadProteinAdequacy(), loadProteinAdequacy(),
loadMacroWeekly(),
loadAdherence(), loadAdherence(),
loadWarning() loadWarning(),
]) ]
if (showWeeklyMacroDistribution) {
tasks.splice(2, 0, loadMacroWeekly())
}
await Promise.all(tasks)
} }
const loadEnergyBalance = async () => { const loadEnergyBalance = async () => {
@ -236,12 +298,13 @@ export default function NutritionCharts({ days = 28 }) {
} }
} }
// E1: Energy Balance Timeline (mit 7d/14d Durchschnitten) // E1: Energy Balance klare Farben (kein hellgraues Gewirr)
const renderEnergyBalance = () => { const renderEnergyBalance = () => {
if (!energyData || energyData.metadata?.confidence === 'insufficient') { if (!energyData || energyData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}> const msg = energyData?.metadata?.message || 'Nicht genug Ernährungsdaten für die Energiebilanz.'
Nicht genug Ernährungsdaten (min. 7 Tage) return (
</div> <div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</div>
)
} }
const chartData = energyData.data.labels.map((label, i) => ({ const chartData = energyData.data.labels.map((label, i) => ({
@ -249,7 +312,7 @@ export default function NutritionCharts({ days = 28 }) {
täglich: energyData.data.datasets[0]?.data[i], täglich: energyData.data.datasets[0]?.data[i],
avg7d: energyData.data.datasets[1]?.data[i], avg7d: energyData.data.datasets[1]?.data[i],
avg14d: energyData.data.datasets[2]?.data[i], avg14d: energyData.data.datasets[2]?.data[i],
tdee: energyData.data.datasets[3]?.data[i] tdee: energyData.data.datasets[3]?.data[i],
})) }))
const balance = energyData.metadata?.energy_balance || 0 const balance = energyData.metadata?.energy_balance || 0
@ -257,111 +320,90 @@ export default function NutritionCharts({ days = 28 }) {
return ( return (
<> <>
<ResponsiveContainer width="100%" height={220}> <div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}> Tägliche Aufnahme, gleitende Mittel und geschätzter TDEE Linien sind farblich getrennt (Legende unten).
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/> </div>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} <ResponsiveContainer width="100%" height={240}>
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/> <LineChart data={chartData} margin={{ top: 6, right: 10, bottom: 4, left: -18 }}>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/> <XAxis
<Legend wrapperStyle={{fontSize:10}}/> dataKey="date"
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/> tick={{ fontSize: 9, fill: 'var(--text3)' }}
<Line type="monotone" dataKey="avg7d" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="Ø 7d"/> tickLine={false}
<Line type="monotone" dataKey="avg14d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 14d"/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TDEE"/> />
<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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
<div style={{marginTop:8,fontSize:10,textAlign:'center'}}> <div style={{ marginTop: 8, fontSize: 10, textAlign: 'center', lineHeight: 1.5 }}>
<span style={{color:'var(--text3)'}}> <span style={{ color: 'var(--text3)' }}>Ø {energyData.metadata.avg_kcal} kcal/Tag ·</span>
Ø {energyData.metadata.avg_kcal} kcal/Tag · <span style={{ color: balanceColor, fontWeight: 600, marginLeft: 4 }}>
</span> Balance: {balance > 0 ? '+' : ''}
<span style={{color:balanceColor,fontWeight:600,marginLeft:4}}> {balance} kcal/Tag
Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag
</span>
<span style={{color:'var(--text3)',marginLeft:8}}>
· {energyData.metadata.data_points} Tage
</span> </span>
<span style={{ color: 'var(--text3)', marginLeft: 8 }}>· {energyData.metadata.data_points} Tage</span>
</div> </div>
</> </>
) )
} }
// E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten) // E2: Protein Zielzone als Fläche, Linien klar von E1 abgrenzbar
const renderProteinAdequacy = () => { const renderProteinAdequacy = () => {
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') { if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}> const msg = proteinData?.metadata?.message || 'Nicht genug Protein-Daten für dieses Diagramm.'
Nicht genug Protein-Daten (min. 7 Tage) return (
</div> <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) => ({ const chartData = proteinData.data.labels.map((label, i) => ({
date: fmtDate(label), date: fmtDate(label),
täglich: proteinData.data.datasets[0]?.data[i], täglich: proteinData.data.datasets[0]?.data[i],
avg7d: proteinData.data.datasets[1]?.data[i], avg7d: proteinData.data.datasets[1]?.data[i],
avg28d: proteinData.data.datasets[2]?.data[i], avg28d: proteinData.data.datasets[2]?.data[i],
targetLow: proteinData.data.datasets[3]?.data[i],
targetHigh: proteinData.data.datasets[4]?.data[i]
})) }))
return ( return (
<> <>
<ResponsiveContainer width="100%" height={220}> <div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}> Grüne Zone = empfohlenes Protein-Ziel (g/Tag). Tägliche Werte und Mittel andere Farben als Energiebilanz oben.
<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> </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
// E3: Weekly Macro Distribution (100% gestapelte Balken) dataKey="date"
const renderMacroWeekly = () => { tick={{ fontSize: 9, fill: 'var(--text3)' }}
if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') { tickLine={false}
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
Nicht genug Daten für Wochen-Analyse (min. 7 Tage) />
</div> <YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
} <Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
const chartData = macroWeeklyData.data.labels.map((label, i) => ({ />
week: label, {tl != null && th != null && (
protein: macroWeeklyData.data.datasets[0]?.data[i], <ReferenceArea y1={tl} y2={th} fill="rgba(16, 185, 129, 0.14)" stroke="#10B981" strokeOpacity={0.35} />
carbs: macroWeeklyData.data.datasets[1]?.data[i], )}
fat: macroWeeklyData.data.datasets[2]?.data[i] <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" />
const meta = macroWeeklyData.metadata <Line type="monotone" dataKey="täglich" stroke="#0284C7" strokeWidth={2} dot={{ r: 2 }} name="Täglich g" />
</ComposedChart>
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> </ResponsiveContainer>
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.5 }}>
Ø Verteilung: P {meta.avg_protein_pct}% · C {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · Ziel {tl}{th} g/Tag · {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich (
Konsistenz (CV): P {meta.protein_cv}% · C {meta.carbs_cv}% · F {meta.fat_cv}% {proteinData.metadata.target_compliance_pct}%)
</div> </div>
</> </>
) )
@ -414,17 +456,19 @@ export default function NutritionCharts({ days = 28 }) {
return ( return (
<div> <div>
<ChartCard title="📊 Energiebilanz (E1)" loading={loading.energy} error={errors.energy}> <ChartCard title="📊 Energiebilanz" loading={loading.energy} error={errors.energy}>
{renderEnergyBalance()} {renderEnergyBalance()}
</ChartCard> </ChartCard>
<ChartCard title="📊 Protein-Adequacy (E2)" loading={loading.protein} error={errors.protein}> <ChartCard title="📊 Protein (Adequacy)" loading={loading.protein} error={errors.protein}>
{renderProteinAdequacy()} {renderProteinAdequacy()}
</ChartCard> </ChartCard>
<ChartCard title="📊 Wöchentliche Makro-Verteilung (E3)" loading={loading.macro} error={errors.macro}> {showWeeklyMacroDistribution && (
{renderMacroWeekly()} <ChartCard title="📊 Wöchentliche Makro-Verteilung" loading={loading.macro} error={errors.macro}>
</ChartCard> <WeeklyMacroDistributionPanel macroWeeklyData={macroWeeklyData} loading={false} error={null} />
</ChartCard>
)}
{!loading.adherence && !errors.adherence && renderAdherence()} {!loading.adherence && !errors.adherence && renderAdherence()}
{!loading.warning && !errors.warning && renderWarning()} {!loading.warning && !errors.warning && renderWarning()}

View File

@ -13,7 +13,7 @@ import { getBfCategory } from '../utils/calc'
import { getStatusColor, getStatusBg } from '../utils/interpret' import { getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import NutritionCharts from '../components/NutritionCharts' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts' import RecoveryCharts from '../components/RecoveryCharts'
import KpiTilesOverview from '../components/KpiTilesOverview' import KpiTilesOverview from '../components/KpiTilesOverview'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -233,6 +233,51 @@ function buildBodyKpiTiles({
return tiles return tiles
} }
function NutritionGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Ernährungsbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
function BodyGoalsStrip({ grouped }) { function BodyGoalsStrip({ grouped }) {
const nav = useNavigate() const nav = useNavigate()
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4) const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
@ -653,191 +698,296 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
</div> </div>
) )
} }
// Nutrition Section
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(30)
if (!nutrition?.length) return (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
)
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') function buildNutritionKpiTiles({
const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff) avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date)) }) {
const tiles = [
{
key: 'kcal',
category: 'Kalorien (Ø)',
icon: '🔥',
value: `${avgKcal} kcal`,
sublabel: dateSpanLabel,
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliche tägliche Energie',
hoverBody: `Mittel über ${n} Tage mit Ernährungseinträgen im gewählten Zeitraum.`,
},
{
key: 'carbs',
category: 'KH (Ø)',
icon: '🌾',
value: `${avgCarbs} g`,
sublabel: 'Kohlenhydrate / Tag',
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliche Kohlenhydrate',
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
},
{
key: 'fat',
category: 'Fett (Ø)',
icon: '🧈',
value: `${avgFat} g`,
sublabel: 'Fett / Tag',
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliches Fett',
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
},
]
macroRules.forEach((r, i) => {
tiles.push({
key: `eval-${i}`,
category: r.category,
icon: r.icon,
value: r.value,
sublabel: r.title.length > 36 ? `${r.title.slice(0, 34)}` : r.title,
status: r.status,
verdict: verdictShort(r.status === 'warn' ? 'warn' : r.status === 'bad' ? 'bad' : 'good'),
hoverTop: r.title,
hoverBody: r.detail,
})
})
return tiles
}
if (!filtN.length) return ( /** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */
<div> function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) {
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/> const raw = (corrRows || []).filter(d => {
<PeriodSelector value={period} onChange={setPeriod}/> if (!d.kcal || d.weight == null) return false
<EmptySection text="Keine Einträge im gewählten Zeitraum."/> const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD')
return allTime || ds >= cutoffDate
})
if (raw.length < 5) return null
const sex = profile?.sex || 'm'
const height = profile?.height || 178
const latestW = raw[raw.length - 1]?.weight || 80
const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35
const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161
const tdee = Math.round(bmr * 1.4)
const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal')
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht.
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
<ReferenceLine yAxisId="kcal" y={tdee} stroke="#EA580C" strokeDasharray="6 4" strokeWidth={1.2} />
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
</LineChart>
</ResponsiveContainer>
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 6 }}>
Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage
</div>
</div> </div>
) )
}
// Nutrition Section
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs, corrData }) {
const [period, setPeriod] = useState(30)
const [groupedGoals, setGroupedGoals] = useState(null)
const chartDays = period === 9999 ? 90 : period
const weeks = Math.max(4, Math.min(52, Math.ceil(chartDays / 7)))
const [weeklyMacro, setWeeklyMacro] = useState(null)
const [wmLoading, setWmLoading] = useState(false)
const [wmError, setWmError] = useState(null)
useEffect(() => {
let cancelled = false
api.listGoalsGrouped()
.then(g => { if (!cancelled) setGroupedGoals(g) })
.catch(() => { if (!cancelled) setGroupedGoals({}) })
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
setWmLoading(true)
setWmError(null)
api.getWeeklyMacroDistributionChart(weeks)
.then(d => { if (!cancelled) setWeeklyMacro(d) })
.catch(e => { if (!cancelled) setWmError(e.message || 'Laden fehlgeschlagen') })
.finally(() => { if (!cancelled) setWmLoading(false) })
return () => { cancelled = true }
}, [weeks])
if (!nutrition?.length) {
return (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
)
}
const cutoff = dayjs().subtract(period, 'day').format('YYYY-MM-DD')
const filtN = nutrition.filter(d => period === 9999 || d.date >= cutoff)
const sorted = [...filtN].sort((a, b) => a.date.localeCompare(b.date))
if (!filtN.length) {
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
</div>
)
}
const n = filtN.length const n = filtN.length
const avgKcal = Math.round(filtN.reduce((s,d)=>s+(d.kcal||0),0)/n) const avgKcal = Math.round(filtN.reduce((s, d) => s + (d.kcal || 0), 0) / n)
const avgProtein = Math.round(filtN.reduce((s,d)=>s+(d.protein_g||0),0)/n*10)/10 const avgProtein = Math.round(filtN.reduce((s, d) => s + (d.protein_g || 0), 0) / n * 10) / 10
const avgFat = Math.round(filtN.reduce((s,d)=>s+(d.fat_g||0),0)/n*10)/10 const avgFat = Math.round(filtN.reduce((s, d) => s + (d.fat_g || 0), 0) / n * 10) / 10
const avgCarbs = Math.round(filtN.reduce((s,d)=>s+(d.carbs_g||0),0)/n*10)/10 const avgCarbs = Math.round(filtN.reduce((s, d) => s + (d.carbs_g || 0), 0) / n * 10) / 10
const latestW = weights?.[0]?.weight||80 const latestW = weights?.[0]?.weight || 80
const ptLow = Math.round(latestW*1.6) const ptLow = Math.round(latestW * 1.6)
const ptHigh = Math.round(latestW*2.2) const ptHigh = Math.round(latestW * 2.2)
const proteinOk = avgProtein>=ptLow const proteinOk = avgProtein >= ptLow
// Stacked macro bar (daily) const cdMacro = sorted.map(d => ({
const cdMacro = sorted.map(d=>({
date: fmtDate(d.date), date: fmtDate(d.date),
Protein: Math.round(d.protein_g||0), Protein: Math.round(d.protein_g || 0),
KH: Math.round(d.carbs_g||0), KH: Math.round(d.carbs_g || 0),
Fett: Math.round(d.fat_g||0), Fett: Math.round(d.fat_g || 0),
kcal: Math.round(d.kcal||0), kcal: Math.round(d.kcal || 0),
})) }))
// Pie const totalMacroKcal = avgProtein * 4 + avgCarbs * 4 + avgFat * 9
const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9
const pieData = [ const pieData = [
{name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'}, { name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' },
{name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'}, { name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' },
{name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'}, { name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' },
] ]
// Weekly macro bars const macroRules = []
const weeklyMap={} if (!proteinOk) {
filtN.forEach(d=>{ macroRules.push({
const wk=dayjs(d.date).format('YYYY-WW') status: 'bad', icon: '🥩', category: 'Protein',
const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })() title: `Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0} detail: `1,62,2g/kg KG. Fehlend: ~${Math.max(0, ptLow - Math.round(avgProtein))}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0 value: `${avgProtein}g`,
weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++ })
}) } else {
const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({ macroRules.push({
label:w.label, status: 'good', icon: '🥩', category: 'Protein',
Protein:Math.round(w.protein/w.n), title: `Gut: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
KH:Math.round(w.carbs/w.n), detail: 'Ausreichend für Muskelerhalt und -aufbau.',
Fett:Math.round(w.fat/w.n), value: `${avgProtein}g`,
kcal:Math.round(w.kcal/w.n), })
})) }
const protPct = Math.round(avgProtein * 4 / totalMacroKcal * 100)
if (protPct < 20) {
macroRules.push({
status: 'warn', icon: '📊', category: 'Makro-Anteil',
title: `Protein-Anteil niedrig: ${protPct}% der Kalorien`,
detail: `Empfehlung oft 2535%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`,
value: `${protPct}%`,
})
}
// Rules const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}`
const macroRules=[] const kpiTiles = buildNutritionKpiTiles({
if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein', avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
title:`Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`, })
detail:`1,62,2g/kg KG. Fehlend: ~${ptLow-Math.round(avgProtein)}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
value:avgProtein+'g'})
else macroRules.push({status:'good',icon:'🥩',category:'Protein',
title:`Gut: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail:`Ausreichend für Muskelerhalt und -aufbau.`,value:avgProtein+'g'})
const protPct=Math.round(avgProtein*4/totalMacroKcal*100)
if(protPct<20) macroRules.push({status:'warn',icon:'📊',category:'Makro-Anteil',
title:`Protein-Anteil niedrig: ${protPct}% der Kalorien`,
detail:`Empfehlung: 2535%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`,
value:protPct+'%'})
return ( return (
<div> <div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/> <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/> <PeriodSelector value={period} onChange={setPeriod}/>
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}> <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'], Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '}
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'], <strong>Kalorien vs. Gewicht</strong> bezieht gemeinsame Tage aus Ernährung und Gewicht.
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>( </p>
<div key={l} style={{flex:1,minWidth:60,background:'var(--surface2)',borderRadius:8,
padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:13,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
{/* Stacked macro bars (daily) */} <NutritionGoalsStrip grouped={groupedGoals} />
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}> <KpiTilesOverview tiles={kpiTiles} />
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} {sorted[sorted.length-1]?.date?.slice(0,7)}
<KcalVsWeightChart corrData={corrData} profile={profile} cutoffDate={cutoff} allTime={period === 9999} />
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Makroverteilung täglich (g) · Fokus Protein
</div> </div>
<ResponsiveContainer width="100%" height={170}> <div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
<BarChart data={cdMacro} margin={{top:4,right:8,bottom:0,left:-20}}> Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht).
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/> </div>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} <ResponsiveContainer width="100%" height={200}>
interval={Math.max(0,Math.floor(cdMacro.length/6)-1)}/> <BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5} <XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(cdMacro.length / 6) - 1)} />
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/> <YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)', <ReferenceLine y={ptLow} stroke="#059669" strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: '#059669', position: 'insideTopRight' }} />
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/> <Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/> <Bar dataKey="Fett" stackId="a" fill="#93C5FD" name="Fett" />
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/> <Bar dataKey="KH" stackId="a" fill="#FDBA74" name="KH" />
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[2,2,0,0]}/> <Bar dataKey="Protein" stackId="a" fill="#059669" name="Protein" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}> <div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E7599',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Protein</span> <span><span style={{ display: 'inline-block', width: 10, height: 10, background: '#059669', borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (oben)</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>KH</span> <span><span style={{ display: 'inline-block', width: 10, height: 10, background: '#FDBA74', borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />KH</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Fett</span> <span><span style={{ display: 'inline-block', width: 10, height: 10, background: '#93C5FD', borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett</span>
<span><span style={{display:'inline-block',width:14,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #1D9E75'}}/>Protein-Ziel</span>
</div> </div>
</div> </div>
{/* Pie + macro breakdown */} <div className="nutrition-macro-pair">
<div className="card" style={{marginBottom:12}}> <div className="card nutrition-macro-pair__donut">
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:10}}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} {sorted[sorted.length-1]?.date?.slice(0,10)}) Ø Makro-Quote ({n} Tage)
</div> </div>
<div style={{display:'flex',alignItems:'center',gap:16}}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<PieChart width={110} height={110}> <PieChart width={120} height={120}>
<Pie data={pieData} cx={50} cy={50} innerRadius={32} outerRadius={50} <Pie data={pieData} cx={58} cy={58} innerRadius={36} outerRadius={54} dataKey="value" startAngle={90} endAngle={-270}>
dataKey="value" startAngle={90} endAngle={-270}> {pieData.map((e, i) => <Cell key={i} fill={e.color} />)}
{pieData.map((e,i)=><Cell key={i} fill={e.color}/>)} </Pie>
</Pie> <Tooltip formatter={(v, name) => [`${v}%`, name]} />
<Tooltip formatter={(v,n)=>[`${v}%`,n]}/> </PieChart>
</PieChart> <div style={{ flex: 1, minWidth: 160 }}>
<div style={{flex:1}}> {pieData.map(p => (
{pieData.map(p=>( <div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div key={p.name} style={{display:'flex',alignItems:'center',gap:8,marginBottom:7}}> <div style={{ width: 10, height: 10, borderRadius: 2, background: p.color, flexShrink: 0 }} />
<div style={{width:10,height:10,borderRadius:2,background:p.color,flexShrink:0}}/> <div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{flex:1,fontSize:13}}>{p.name}</div> <div style={{ fontSize: 13, fontWeight: 600, color: p.color }}>{p.value}%</div>
<div style={{fontSize:13,fontWeight:600,color:p.color}}>{p.value}%</div> <div style={{ fontSize: 11, color: 'var(--text3)' }}>
<div style={{fontSize:11,color:'var(--text3)'}}>{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g</div> {Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}g
{p.name==='Protein' && <div style={{fontSize:10,color:proteinOk?'var(--accent)':'var(--warn)',marginLeft:2}}> </div>
{proteinOk?'✓':'⚠️'} Ziel {ptLow}g </div>
</div>} ))}
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
</div> </div>
))}
<div style={{marginTop:6,fontSize:11,color:'var(--text3)',borderTop:'1px solid var(--border)',paddingTop:6}}>
Gesamt: {avgKcal} kcal/Tag
</div> </div>
</div> </div>
</div> </div>
</div> <div className="card nutrition-macro-pair__weekly">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
{/* Weekly stacked bars */} Wöchentliche Makro-Verteilung (Backend)
{weeklyData.length>=2 && ( </div>
<div className="card" style={{marginBottom:12}}> <WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={wmLoading} error={wmError} />
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Makros pro Woche (Ø g/Tag)</div>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="label" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div> </div>
)}
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div> </div>
{/* New Nutrition Charts (Phase 0c) */} <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
<div style={{marginTop:16}}> Zeitverläufe (Energie & Protein)
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>📊 DETAILLIERTE CHARTS</div>
<NutritionCharts days={period === 9999 ? 90 : period} />
</div> </div>
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/> <InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
</div> </div>
@ -964,10 +1114,7 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161 const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161
const tdee = Math.round(bmr*1.4) // light activity baseline const tdee = Math.round(bmr*1.4) // light activity baseline
// Chart 1: Kcal vs Weight // Protein vs Lean Mass (only days with both)
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
// Chart 2: Protein vs Lean Mass (only days with both)
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass) const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass})) .map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass}))
@ -1043,31 +1190,11 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
<div> <div>
<SectionHeader title="🔗 Korrelationen"/> <SectionHeader title="🔗 Korrelationen"/>
{/* Chart 1: Kcal vs Weight */} <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
<div className="card" style={{marginBottom:12}}> Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf Ernährung</strong> (gleiche Datenbasis).
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}> </p>
📉 Kalorien (Ø 7T) vs. Gewicht
</div>
<ResponsiveContainer width="100%" height={190}>
<LineChart data={kcalVsW} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(kcalVsW.length/6)-1)}/>
<YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`,n==='kcal_avg'?'Ø Kalorien':'Gewicht']}/>
<ReferenceLine yAxisId="kcal" y={tdee} stroke="var(--text3)" strokeDasharray="3 3" strokeWidth={1}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2.5} dot={{r:2,fill:'#378ADD'}} name="weight"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
Gestrichelt: geschätzter TDEE {tdee} kcal · <span style={{color:'#EF9F27'}}> Kalorien</span> · <span style={{color:'#378ADD'}}> Gewicht</span>
</div>
</div>
{/* Chart 2: Calorie balance */} {/* Chart: Calorie balance */}
<div className="card" style={{marginBottom:12}}> <div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}> <div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Kalorienbilanz (Aufnahme TDEE {tdee} kcal) Kalorienbilanz (Aufnahme TDEE {tdee} kcal)
@ -1374,7 +1501,7 @@ export default function History() {
</nav> </nav>
<div className="history-content"> <div className="history-content">
{tab==='body' && <BodySection profile={profile} {...sp}/>} {tab==='body' && <BodySection profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>} {tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} corrData={corrData} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>} {tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='recovery' && <RecoverySection {...sp}/>} {tab==='recovery' && <RecoverySection {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>} {tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}