feat: add weekly macro distribution panel and enhance nutrition charts
- 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:
parent
08b7aa0ca1
commit
a8eafa8ba4
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 % 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 (E1–E5). 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()}
|
||||
|
|
|
|||
|
|
@ -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,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 (
|
||||
<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}/>}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user