- Ø 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}%
+
+ Ziel {tl}–{th} g/Tag · {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich (
+ {proteinData.metadata.target_compliance_pct}%)
>
)
@@ -414,17 +456,19 @@ export default function NutritionCharts({ days = 28 }) {
return (
-
+
{renderEnergyBalance()}
-
+
{renderProteinAdequacy()}
-
- {renderMacroWeekly()}
-
+ {showWeeklyMacroDistribution && (
+
+
+
+ )}
{!loading.adherence && !errors.adherence && renderAdherence()}
{!loading.warning && !errors.warning && renderWarning()}
diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx
index 587a45f..9e18919 100644
--- a/frontend/src/pages/History.jsx
+++ b/frontend/src/pages/History.jsx
@@ -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 (
+
+
+
Ernährungsbezogene Ziele
+
+
+
+ {goals.map(g => (
+
+
{g.name || g.label_de || g.goal_type}
+
+
+ {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
+
+
+ ))}
+
+
+ )
+}
+
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
)
}
-// ── Nutrition Section ─────────────────────────────────────────────────────────
-function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
- const [period, setPeriod] = useState(30)
- if (!nutrition?.length) return (
-
- )
- 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 (
-
-
-
-
+/** 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 (
+
+
+ Kalorien (Ø 7 Tage) vs. Gewicht
+
+
+ Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht.
+
+
+
+
+
+
+
+ [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
+ />
+
+
+
+
+
+
+ Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage
+
)
+}
+
+// ── 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 (
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
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,6–2,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 25–35%. 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,6–2,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: 25–35%. 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 (
-
- {[['Ø 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])=>(
-
- ))}
-
+
+ Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '}
+ Kalorien vs. Gewicht bezieht gemeinsame Tage aus Ernährung und Gewicht.
+
- {/* Stacked macro bars (daily) */}
-
-
- Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} – {sorted[sorted.length-1]?.date?.slice(0,7)}
+
+
+
+
+
+
+
+
+ Makroverteilung täglich (g) · Fokus Protein
-
-
-
-
-
-
- [`${v}g`,n]}/>
-
-
-
+
+ Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht).
+
+
+
+
+
+
+
+ [`${v}g`, name]} />
+
+
+
-
-
Protein
- KH
- Fett
- Protein-Ziel
+
+ Protein (oben)
+ KH
+ Fett
- {/* Pie + macro breakdown */}
-
-
- Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} – {sorted[sorted.length-1]?.date?.slice(0,10)})
-
-
-
-
- {pieData.map((e,i)=>)}
- |
- [`${v}%`,n]}/>
-
-
- {pieData.map(p=>(
-
-
-
{p.name}
-
{p.value}%
-
{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g
- {p.name==='Protein' &&
- {proteinOk?'✓':'⚠️'} Ziel {ptLow}g
-
}
+
+
+
+ Ø Makro-Quote ({n} Tage)
+
+
+
+
+ {pieData.map((e, i) => | )}
+
+ [`${v}%`, name]} />
+
+
+ {pieData.map(p => (
+
+
+
{p.name}
+
{p.value}%
+
+ {Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}g
+
+
+ ))}
+
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
- ))}
-
- Gesamt: {avgKcal} kcal/Tag
-
-
- {/* Weekly stacked bars */}
- {weeklyData.length>=2 && (
-
-
Makros pro Woche (Ø g/Tag)
-
-
-
-
-
-
- [`${v}g`,n]}/>
-
-
-
-
-
+
+
+ Wöchentliche Makro-Verteilung (Backend)
+
+
- )}
-
-
-
BEWERTUNG
- {macroRules.map((item,i)=>
)}
- {/* New Nutrition Charts (Phase 0c) */}
-
-
📊 DETAILLIERTE CHARTS
-
+
+ Zeitverläufe (Energie & Protein)
+
@@ -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
- {/* Chart 1: Kcal vs Weight */}
-
-
- 📉 Kalorien (Ø 7T) vs. Gewicht
-
-
-
-
-
-
-
- [`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`,n==='kcal_avg'?'Ø Kalorien':'Gewicht']}/>
-
-
-
-
-
-
- Gestrichelt: geschätzter TDEE {tdee} kcal · — Kalorien · — Gewicht
-
-
+
+ Das Diagramm Kalorien (Ø 7T) vs. Gewicht liegt unter Verlauf → Ernährung (gleiche Datenbasis).
+
- {/* Chart 2: Calorie balance */}
+ {/* Chart: Calorie balance */}
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
@@ -1374,7 +1501,7 @@ export default function History() {
{tab==='body' &&
}
- {tab==='nutrition' &&
}
+ {tab==='nutrition' &&
}
{tab==='activity' &&
}
{tab==='recovery' &&
}
{tab==='correlation' &&
}