feat: add weekly macro distribution panel and enhance nutrition charts
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Introduced the WeeklyMacroDistributionPanel component to visualize weekly macro distribution alongside existing nutrition charts.
- Updated the NutritionCharts component to conditionally load the weekly macro data based on a new prop.
- Enhanced CSS styles for better layout and responsiveness of the new macro distribution panel.
- Added a new NutritionGoalsStrip component to display active nutrition-related goals with progress indicators in the History page.
- Refactored existing components to improve data handling and user experience.
This commit is contained in:
Lars 2026-04-19 17:13:59 +02:00
parent 08b7aa0ca1
commit a8eafa8ba4
3 changed files with 475 additions and 286 deletions

View File

@ -350,6 +350,24 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
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 {
margin-bottom: 12px;
}

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
ComposedChart, ReferenceArea,
} from 'recharts'
import { api } from '../utils/api'
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
*
* 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)
* Nutrition Charts (E1E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z.B. neben Donut) gerendert wird.
*/
export default function NutritionCharts({ days = 28 }) {
export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true }) {
const [energyData, setEnergyData] = useState(null)
const [proteinData, setProteinData] = useState(null)
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
@ -159,16 +218,19 @@ export default function NutritionCharts({ days = 28 }) {
useEffect(() => {
loadCharts()
}, [days])
}, [days, showWeeklyMacroDistribution])
const loadCharts = async () => {
await Promise.all([
const tasks = [
loadEnergyBalance(),
loadProteinAdequacy(),
loadMacroWeekly(),
loadAdherence(),
loadWarning()
])
loadWarning(),
]
if (showWeeklyMacroDistribution) {
tasks.splice(2, 0, loadMacroWeekly())
}
await Promise.all(tasks)
}
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 = () => {
if (!energyData || energyData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
Nicht genug Ernährungsdaten (min. 7 Tage)
</div>
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) => ({
@ -249,7 +312,7 @@ export default function NutritionCharts({ days = 28 }) {
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]
tdee: energyData.data.datasets[3]?.data[i],
}))
const balance = energyData.metadata?.energy_balance || 0
@ -257,111 +320,90 @@ export default function NutritionCharts({ days = 28 }) {
return (
<>
<ResponsiveContainer width="100%" height={220}>
<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}}/>
<Legend wrapperStyle={{fontSize:10}}/>
<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="avg14d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 14d"/>
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TDEE"/>
<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'}}>
<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
<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 Adequacy Timeline (mit 7d/28d Durchschnitten)
// E2: Protein Zielzone als Fläche, Linien klar von E1 abgrenzbar
const renderProteinAdequacy = () => {
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
Nicht genug Protein-Daten (min. 7 Tage)
</div>
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],
targetLow: proteinData.data.datasets[3]?.data[i],
targetHigh: proteinData.data.datasets[4]?.data[i]
}))
return (
<>
<ResponsiveContainer width="100%" height={220}>
<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}}/>
<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 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>
</>
)
}
// E3: Weekly Macro Distribution (100% gestapelte Balken)
const renderMacroWeekly = () => {
if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
Nicht genug Daten für Wochen-Analyse (min. 7 Tage)
</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 (
<>
<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 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'}}>
Ø Verteilung: P {meta.avg_protein_pct}% · C {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% ·
Konsistenz (CV): P {meta.protein_cv}% · C {meta.carbs_cv}% · F {meta.fat_cv}%
<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>
</>
)
@ -414,17 +456,19 @@ export default function NutritionCharts({ days = 28 }) {
return (
<div>
<ChartCard title="📊 Energiebilanz (E1)" loading={loading.energy} error={errors.energy}>
<ChartCard title="📊 Energiebilanz" loading={loading.energy} error={errors.energy}>
{renderEnergyBalance()}
</ChartCard>
<ChartCard title="📊 Protein-Adequacy (E2)" loading={loading.protein} error={errors.protein}>
<ChartCard title="📊 Protein (Adequacy)" loading={loading.protein} error={errors.protein}>
{renderProteinAdequacy()}
</ChartCard>
<ChartCard title="📊 Wöchentliche Makro-Verteilung (E3)" loading={loading.macro} error={errors.macro}>
{renderMacroWeekly()}
</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()}

View File

@ -13,7 +13,7 @@ import { getBfCategory } from '../utils/calc'
import { getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import NutritionCharts from '../components/NutritionCharts'
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts'
import KpiTilesOverview from '../components/KpiTilesOverview'
import dayjs from 'dayjs'
@ -233,6 +233,51 @@ function buildBodyKpiTiles({
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 }) {
const nav = useNavigate()
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
@ -653,191 +698,296 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
</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')
const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff)
const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date))
function buildNutritionKpiTiles({
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
}) {
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 (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
/** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */
function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) {
const raw = (corrRows || []).filter(d => {
if (!d.kcal || d.weight == null) return false
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>
)
}
// 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 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 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 latestW = weights?.[0]?.weight||80
const ptLow = Math.round(latestW*1.6)
const ptHigh = Math.round(latestW*2.2)
const proteinOk = avgProtein>=ptLow
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 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 latestW = weights?.[0]?.weight || 80
const ptLow = Math.round(latestW * 1.6)
const ptHigh = Math.round(latestW * 2.2)
const proteinOk = avgProtein >= ptLow
// Stacked macro bar (daily)
const cdMacro = sorted.map(d=>({
const cdMacro = sorted.map(d => ({
date: fmtDate(d.date),
Protein: Math.round(d.protein_g||0),
KH: Math.round(d.carbs_g||0),
Fett: Math.round(d.fat_g||0),
kcal: Math.round(d.kcal||0),
Protein: Math.round(d.protein_g || 0),
KH: Math.round(d.carbs_g || 0),
Fett: Math.round(d.fat_g || 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 = [
{name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'},
{name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'},
{name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'},
{ name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' },
{ name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' },
{ name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' },
]
// Weekly macro bars
const weeklyMap={}
filtN.forEach(d=>{
const wk=dayjs(d.date).format('YYYY-WW')
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) })()
if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0}
weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0
weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++
})
const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({
label:w.label,
Protein:Math.round(w.protein/w.n),
KH:Math.round(w.carbs/w.n),
Fett:Math.round(w.fat/w.n),
kcal:Math.round(w.kcal/w.n),
}))
const macroRules = []
if (!proteinOk) {
macroRules.push({
status: 'bad', icon: '🥩', category: 'Protein',
title: `Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail: `1,62,2g/kg KG. Fehlend: ~${Math.max(0, 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 oft 2535%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`,
value: `${protPct}%`,
})
}
// Rules
const macroRules=[]
if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein',
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+'%'})
const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}`
const kpiTiles = buildNutritionKpiTiles({
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
})
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'],
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'],
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>(
<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>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '}
<strong>Kalorien vs. Gewicht</strong> bezieht gemeinsame Tage aus Ernährung und Gewicht.
</p>
{/* Stacked macro bars (daily) */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} {sorted[sorted.length-1]?.date?.slice(0,7)}
<NutritionGoalsStrip grouped={groupedGoals} />
<KpiTilesOverview tiles={kpiTiles} />
<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>
<ResponsiveContainer width="100%" height={170}>
<BarChart data={cdMacro} 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(cdMacro.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/>
<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={[2,2,0,0]}/>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht).
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, 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(cdMacro.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<ReferenceLine y={ptLow} stroke="#059669" strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: '#059669', position: 'insideTopRight' }} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Fett" stackId="a" fill="#93C5FD" name="Fett" />
<Bar dataKey="KH" stackId="a" fill="#FDBA74" name="KH" />
<Bar dataKey="Protein" stackId="a" fill="#059669" name="Protein" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<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:'#D4537E99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>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:14,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #1D9E75'}}/>Protein-Ziel</span>
<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: '#059669', borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (oben)</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: '#93C5FD', borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett</span>
</div>
</div>
{/* Pie + macro breakdown */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:10}}>
Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} {sorted[sorted.length-1]?.date?.slice(0,10)})
</div>
<div style={{display:'flex',alignItems:'center',gap:16}}>
<PieChart width={110} height={110}>
<Pie data={pieData} cx={50} cy={50} innerRadius={32} outerRadius={50}
dataKey="value" startAngle={90} endAngle={-270}>
{pieData.map((e,i)=><Cell key={i} fill={e.color}/>)}
</Pie>
<Tooltip formatter={(v,n)=>[`${v}%`,n]}/>
</PieChart>
<div style={{flex:1}}>
{pieData.map(p=>(
<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={{flex:1,fontSize:13}}>{p.name}</div>
<div style={{fontSize:13,fontWeight:600,color:p.color}}>{p.value}%</div>
<div style={{fontSize:11,color:'var(--text3)'}}>{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g</div>
{p.name==='Protein' && <div style={{fontSize:10,color:proteinOk?'var(--accent)':'var(--warn)',marginLeft:2}}>
{proteinOk?'✓':'⚠️'} Ziel {ptLow}g
</div>}
<div className="nutrition-macro-pair">
<div className="card nutrition-macro-pair__donut">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Ø Makro-Quote ({n} Tage)
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<PieChart width={120} height={120}>
<Pie data={pieData} cx={58} cy={58} innerRadius={36} outerRadius={54} dataKey="value" startAngle={90} endAngle={-270}>
{pieData.map((e, i) => <Cell key={i} fill={e.color} />)}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
<div style={{ flex: 1, minWidth: 160 }}>
{pieData.map(p => (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: p.color, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: p.color }}>{p.value}%</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}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 style={{marginTop:6,fontSize:11,color:'var(--text3)',borderTop:'1px solid var(--border)',paddingTop:6}}>
Gesamt: {avgKcal} kcal/Tag
</div>
</div>
</div>
</div>
{/* Weekly stacked bars */}
{weeklyData.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<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 className="card nutrition-macro-pair__weekly">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
Wöchentliche Makro-Verteilung (Backend)
</div>
<WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={wmLoading} error={wmError} />
</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>
{/* New Nutrition Charts (Phase 0c) */}
<div style={{marginTop:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>📊 DETAILLIERTE CHARTS</div>
<NutritionCharts days={period === 9999 ? 90 : period} />
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
Zeitverläufe (Energie & Protein)
</div>
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
</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 tdee = Math.round(bmr*1.4) // light activity baseline
// Chart 1: Kcal vs Weight
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
// Chart 2: Protein vs Lean Mass (only days with both)
// Protein vs Lean Mass (only days with both)
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}))
@ -1043,31 +1190,11 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
<div>
<SectionHeader title="🔗 Korrelationen"/>
{/* Chart 1: Kcal vs Weight */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
📉 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>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf Ernährung</strong> (gleiche Datenbasis).
</p>
{/* Chart 2: Calorie balance */}
{/* Chart: Calorie balance */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Kalorienbilanz (Aufnahme TDEE {tdee} kcal)
@ -1374,7 +1501,7 @@ export default function History() {
</nav>
<div className="history-content">
{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==='recovery' && <RecoverySection {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}