From d4500ca00ce81cee05666d6eef29341bcd5e1bb8 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 29 Mar 2026 07:02:54 +0200 Subject: [PATCH] feat: Phase 0c Frontend Phase 1 - Nutrition + Recovery Charts - Create NutritionCharts component (E1-E5) - Energy Balance Timeline - Macro Distribution (Pie) - Protein Adequacy Timeline - Nutrition Consistency Score - Create RecoveryCharts component (R1-R5) - Recovery Score Timeline - HRV/RHR vs Baseline (dual-axis) - Sleep Duration + Quality (dual-axis) - Sleep Debt Accumulation - Vital Signs Matrix (horizontal bar) - Add 9 chart API functions to api.js - 4 nutrition endpoints (E1-E5) - 5 recovery endpoints (R1-R5) - Integrate into History page - Add NutritionCharts to existing Nutrition tab - Create new Recovery tab with RecoveryCharts - Period selector controls chart timeframe Charts use Recharts (existing dependency) All charts display Chart.js-compatible data from backend Confidence handling: Show 'Nicht genug Daten' message Files: + frontend/src/components/NutritionCharts.jsx (329 lines) + frontend/src/components/RecoveryCharts.jsx (342 lines) M frontend/src/utils/api.js (+14 functions) M frontend/src/pages/History.jsx (+22 lines, new Recovery tab) --- frontend/src/components/NutritionCharts.jsx | 284 +++++++++++++++++ frontend/src/components/RecoveryCharts.jsx | 320 ++++++++++++++++++++ frontend/src/pages/History.jsx | 32 ++ frontend/src/utils/api.js | 14 + 4 files changed, 650 insertions(+) create mode 100644 frontend/src/components/NutritionCharts.jsx create mode 100644 frontend/src/components/RecoveryCharts.jsx diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx new file mode 100644 index 0000000..e60c65b --- /dev/null +++ b/frontend/src/components/NutritionCharts.jsx @@ -0,0 +1,284 @@ +import { useState, useEffect } from 'react' +import { + LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, + XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, + ReferenceLine +} from 'recharts' +import { api } from '../utils/api' +import dayjs from 'dayjs' + +const fmtDate = d => dayjs(d).format('DD.MM') + +function ChartCard({ title, loading, error, children }) { + return ( +
+
+ {title} +
+ {loading && ( +
+
+
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && !error && children} +
+ ) +} + +/** + * Nutrition Charts Component (E1-E5) + * + * Displays 4 nutrition chart endpoints: + * - Energy Balance Timeline (E1) + * - Macro Distribution (E2) + * - Protein Adequacy (E3) + * - Nutrition Consistency (E5) + */ +export default function NutritionCharts({ days = 28 }) { + const [energyData, setEnergyData] = useState(null) + const [macroData, setMacroData] = useState(null) + const [proteinData, setProteinData] = useState(null) + const [consistencyData, setConsistencyData] = useState(null) + + const [loading, setLoading] = useState({}) + const [errors, setErrors] = useState({}) + + useEffect(() => { + loadCharts() + }, [days]) + + const loadCharts = async () => { + // Load all 4 charts in parallel + await Promise.all([ + loadEnergyBalance(), + loadMacroDistribution(), + loadProteinAdequacy(), + loadConsistency() + ]) + } + + const loadEnergyBalance = async () => { + setLoading(l => ({...l, energy: true})) + setErrors(e => ({...e, energy: null})) + try { + const data = await api.getEnergyBalanceChart(days) + setEnergyData(data) + } catch (err) { + setErrors(e => ({...e, energy: err.message})) + } finally { + setLoading(l => ({...l, energy: false})) + } + } + + const loadMacroDistribution = async () => { + setLoading(l => ({...l, macro: true})) + setErrors(e => ({...e, macro: null})) + try { + const data = await api.getMacroDistributionChart(days) + setMacroData(data) + } catch (err) { + setErrors(e => ({...e, macro: err.message})) + } finally { + setLoading(l => ({...l, macro: false})) + } + } + + const loadProteinAdequacy = async () => { + setLoading(l => ({...l, protein: true})) + setErrors(e => ({...e, protein: null})) + try { + const data = await api.getProteinAdequacyChart(days) + setProteinData(data) + } catch (err) { + setErrors(e => ({...e, protein: err.message})) + } finally { + setLoading(l => ({...l, protein: false})) + } + } + + const loadConsistency = async () => { + setLoading(l => ({...l, consistency: true})) + setErrors(e => ({...e, consistency: null})) + try { + const data = await api.getNutritionConsistencyChart(days) + setConsistencyData(data) + } catch (err) { + setErrors(e => ({...e, consistency: err.message})) + } finally { + setLoading(l => ({...l, consistency: false})) + } + } + + // E1: Energy Balance Timeline + const renderEnergyBalance = () => { + if (!energyData || energyData.metadata?.confidence === 'insufficient') { + return
+ Nicht genug Ernährungsdaten +
+ } + + const chartData = energyData.data.labels.map((label, i) => ({ + date: fmtDate(label), + kcal: energyData.data.datasets[0]?.data[i], + tdee: energyData.data.datasets[1]?.data[i] + })) + + return ( + <> + + + + + + + + + + +
+ Ø {energyData.metadata.avg_kcal} kcal/Tag · {energyData.metadata.data_points} Einträge +
+ + ) + } + + // E2: Macro Distribution (Pie) + const renderMacroDistribution = () => { + if (!macroData || macroData.metadata?.confidence === 'insufficient') { + return
+ Nicht genug Makronährstoff-Daten +
+ } + + const chartData = macroData.data.labels.map((label, i) => ({ + name: label, + value: macroData.data.datasets[0]?.data[i], + color: macroData.data.datasets[0]?.backgroundColor[i] + })) + + return ( + <> + + + `${name}: ${value}%`} + outerRadius={70} + dataKey="value" + > + {chartData.map((entry, index) => ( + + ))} + + + + +
+ P: {macroData.metadata.protein_g}g · C: {macroData.metadata.carbs_g}g · F: {macroData.metadata.fat_g}g +
+ + ) + } + + // E3: Protein Adequacy Timeline + const renderProteinAdequacy = () => { + if (!proteinData || proteinData.metadata?.confidence === 'insufficient') { + return
+ Nicht genug Protein-Daten +
+ } + + const chartData = proteinData.data.labels.map((label, i) => ({ + date: fmtDate(label), + protein: proteinData.data.datasets[0]?.data[i], + targetLow: proteinData.data.datasets[1]?.data[i], + targetHigh: proteinData.data.datasets[2]?.data[i] + })) + + return ( + <> + + + + + + + + + + + +
+ {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ({proteinData.metadata.target_compliance_pct}%) +
+ + ) + } + + // E5: Nutrition Consistency (Bar) + const renderConsistency = () => { + if (!consistencyData || consistencyData.metadata?.confidence === 'insufficient') { + return
+ Nicht genug Daten für Konsistenz-Analyse +
+ } + + const chartData = consistencyData.data.labels.map((label, i) => ({ + name: label, + score: consistencyData.data.datasets[0]?.data[i], + color: consistencyData.data.datasets[0]?.backgroundColor[i] + })) + + return ( + <> + + + + + + + + {chartData.map((entry, index) => ( + + ))} + + + +
+ Gesamt-Score: {consistencyData.metadata.consistency_score}/100 +
+ + ) + } + + return ( +
+ + {renderEnergyBalance()} + + + + {renderMacroDistribution()} + + + + {renderProteinAdequacy()} + + + + {renderConsistency()} + +
+ ) +} diff --git a/frontend/src/components/RecoveryCharts.jsx b/frontend/src/components/RecoveryCharts.jsx new file mode 100644 index 0000000..a07cdda --- /dev/null +++ b/frontend/src/components/RecoveryCharts.jsx @@ -0,0 +1,320 @@ +import { useState, useEffect } from 'react' +import { + LineChart, Line, BarChart, Bar, + XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid +} from 'recharts' +import { api } from '../utils/api' +import dayjs from 'dayjs' + +const fmtDate = d => dayjs(d).format('DD.MM') + +function ChartCard({ title, loading, error, children }) { + return ( +
+
+ {title} +
+ {loading && ( +
+
+
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && !error && children} +
+ ) +} + +/** + * Recovery Charts Component (R1-R5) + * + * Displays 5 recovery chart endpoints: + * - Recovery Score Timeline (R1) + * - HRV/RHR vs Baseline (R2) + * - Sleep Duration + Quality (R3) + * - Sleep Debt (R4) + * - Vital Signs Matrix (R5) + */ +export default function RecoveryCharts({ days = 28 }) { + const [recoveryData, setRecoveryData] = useState(null) + const [hrvRhrData, setHrvRhrData] = useState(null) + const [sleepData, setSleepData] = useState(null) + const [debtData, setDebtData] = useState(null) + const [vitalsData, setVitalsData] = useState(null) + + const [loading, setLoading] = useState({}) + const [errors, setErrors] = useState({}) + + useEffect(() => { + loadCharts() + }, [days]) + + const loadCharts = async () => { + // Load all 5 charts in parallel + await Promise.all([ + loadRecoveryScore(), + loadHrvRhr(), + loadSleepQuality(), + loadSleepDebt(), + loadVitalSigns() + ]) + } + + const loadRecoveryScore = async () => { + setLoading(l => ({...l, recovery: true})) + setErrors(e => ({...e, recovery: null})) + try { + const data = await api.getRecoveryScoreChart(days) + setRecoveryData(data) + } catch (err) { + setErrors(e => ({...e, recovery: err.message})) + } finally { + setLoading(l => ({...l, recovery: false})) + } + } + + const loadHrvRhr = async () => { + setLoading(l => ({...l, hrvRhr: true})) + setErrors(e => ({...e, hrvRhr: null})) + try { + const data = await api.getHrvRhrBaselineChart(days) + setHrvRhrData(data) + } catch (err) { + setErrors(e => ({...e, hrvRhr: err.message})) + } finally { + setLoading(l => ({...l, hrvRhr: false})) + } + } + + const loadSleepQuality = async () => { + setLoading(l => ({...l, sleep: true})) + setErrors(e => ({...e, sleep: null})) + try { + const data = await api.getSleepDurationQualityChart(days) + setSleepData(data) + } catch (err) { + setErrors(e => ({...e, sleep: err.message})) + } finally { + setLoading(l => ({...l, sleep: false})) + } + } + + const loadSleepDebt = async () => { + setLoading(l => ({...l, debt: true})) + setErrors(e => ({...e, debt: null})) + try { + const data = await api.getSleepDebtChart(days) + setDebtData(data) + } catch (err) { + setErrors(e => ({...e, debt: err.message})) + } finally { + setLoading(l => ({...l, debt: false})) + } + } + + const loadVitalSigns = async () => { + setLoading(l => ({...l, vitals: true})) + setErrors(e => ({...e, vitals: null})) + try { + const data = await api.getVitalSignsMatrixChart(7) // Last 7 days + setVitalsData(data) + } catch (err) { + setErrors(e => ({...e, vitals: err.message})) + } finally { + setLoading(l => ({...l, vitals: false})) + } + } + + // R1: Recovery Score Timeline + const renderRecoveryScore = () => { + if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { + return
+ Keine Recovery-Daten vorhanden +
+ } + + const chartData = recoveryData.data.labels.map((label, i) => ({ + date: fmtDate(label), + score: recoveryData.data.datasets[0]?.data[i] + })) + + return ( + <> + + + + + + + + + +
+ Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge +
+ + ) + } + + // R2: HRV/RHR vs Baseline + const renderHrvRhr = () => { + if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') { + return
+ Keine Vitalwerte vorhanden +
+ } + + const chartData = hrvRhrData.data.labels.map((label, i) => ({ + date: fmtDate(label), + hrv: hrvRhrData.data.datasets[0]?.data[i], + rhr: hrvRhrData.data.datasets[1]?.data[i] + })) + + return ( + <> + + + + + + + + + + + +
+ HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm +
+ + ) + } + + // R3: Sleep Duration + Quality + const renderSleepQuality = () => { + if (!sleepData || sleepData.metadata?.confidence === 'insufficient') { + return
+ Keine Schlafdaten vorhanden +
+ } + + const chartData = sleepData.data.labels.map((label, i) => ({ + date: fmtDate(label), + duration: sleepData.data.datasets[0]?.data[i], + quality: sleepData.data.datasets[1]?.data[i] + })) + + return ( + <> + + + + + + + + + + + +
+ Ø {sleepData.metadata.avg_duration_hours}h Schlaf +
+ + ) + } + + // R4: Sleep Debt + const renderSleepDebt = () => { + if (!debtData || debtData.metadata?.confidence === 'insufficient') { + return
+ Keine Schlafdaten für Schulden-Berechnung +
+ } + + const chartData = debtData.data.labels.map((label, i) => ({ + date: fmtDate(label), + debt: debtData.data.datasets[0]?.data[i] + })) + + return ( + <> + + + + + + + + + +
+ Aktuelle Schuld: {debtData.metadata.current_debt_hours.toFixed(1)}h +
+ + ) + } + + // R5: Vital Signs Matrix (Bar) + const renderVitalSigns = () => { + if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { + return
+ Keine aktuellen Vitalwerte +
+ } + + const chartData = vitalsData.data.labels.map((label, i) => ({ + name: label, + value: vitalsData.data.datasets[0]?.data[i] + })) + + return ( + <> + + + + + + + + + +
+ Letzte {vitalsData.metadata.data_points} Messwerte (7 Tage) +
+ + ) + } + + return ( +
+ + {renderRecoveryScore()} + + + + {renderHrvRhr()} + + + + {renderSleepQuality()} + + + + {renderSleepDebt()} + + + + {renderVitalSigns()} + +
+ ) +} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 9e96686..7a7e61e 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -12,6 +12,8 @@ import { getBfCategory } from '../utils/calc' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' +import NutritionCharts from '../components/NutritionCharts' +import RecoveryCharts from '../components/RecoveryCharts' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -581,6 +583,13 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
BEWERTUNG
{macroRules.map((item,i)=>)}
+ + {/* New Nutrition Charts (Phase 0c) */} +
+
📊 DETAILLIERTE CHARTS
+ +
+
) @@ -915,10 +924,32 @@ function PhotoGrid() { } // ── Main ────────────────────────────────────────────────────────────────────── +// ── Recovery Section ────────────────────────────────────────────────────────── +function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { + const [period, setPeriod] = useState(28) + + return ( +
+ + + +
+ Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick. +
+ + {/* Recovery Charts (Phase 0c) */} + + + +
+ ) +} + const TABS = [ { id:'body', label:'⚖️ Körper' }, { id:'nutrition', label:'🍽️ Ernährung' }, { id:'activity', label:'🏋️ Aktivität' }, + { id:'recovery', label:'😴 Erholung' }, { id:'correlation', label:'🔗 Korrelation' }, { id:'photos', label:'📷 Fotos' }, ] @@ -994,6 +1025,7 @@ export default function History() { {tab==='body' && } {tab==='nutrition' && } {tab==='activity' && } + {tab==='recovery' && } {tab==='correlation' && } {tab==='photos' && } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0139966..024c929 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -374,4 +374,18 @@ export const api = { getUserFocusPreferences: () => req('/focus-areas/user-preferences'), updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)), getFocusAreaStats: () => req('/focus-areas/stats'), + + // Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery) + // Nutrition Charts (E1-E5) + getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), + getMacroDistributionChart: (days=28) => req(`/charts/macro-distribution?days=${days}`), + getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), + getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`), + + // Recovery Charts (R1-R5) + getRecoveryScoreChart: (days=28) => req(`/charts/recovery-score?days=${days}`), + getHrvRhrBaselineChart: (days=28) => req(`/charts/hrv-rhr-baseline?days=${days}`), + getSleepDurationQualityChart: (days=28) => req(`/charts/sleep-duration-quality?days=${days}`), + getSleepDebtChart: (days=28) => req(`/charts/sleep-debt?days=${days}`), + getVitalSignsMatrixChart: (days=7) => req(`/charts/vital-signs-matrix?days=${days}`), }