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)
This commit is contained in:
parent
f81171a1f5
commit
d4500ca00c
284
frontend/src/components/NutritionCharts.jsx
Normal file
284
frontend/src/components/NutritionCharts.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||||
{title}
|
||||
</div>
|
||||
{loading && (
|
||||
<div style={{display:'flex',justifyContent:'center',padding:40}}>
|
||||
<div className="spinner" style={{width:32,height:32}}/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Nicht genug Ernährungsdaten
|
||||
</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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}}/>
|
||||
<Line type="monotone" dataKey="kcal" stroke="#1D9E75" strokeWidth={2} name="Kalorien" dot={{r:2}}/>
|
||||
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="TDEE (geschätzt)" dot={false}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Ø {energyData.metadata.avg_kcal} kcal/Tag · {energyData.metadata.data_points} Einträge
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// E2: Macro Distribution (Pie)
|
||||
const renderMacroDistribution = () => {
|
||||
if (!macroData || macroData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Nicht genug Makronährstoff-Daten
|
||||
</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({name, value}) => `${name}: ${value}%`}
|
||||
outerRadius={70}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
P: {macroData.metadata.protein_g}g · C: {macroData.metadata.carbs_g}g · F: {macroData.metadata.fat_g}g
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// E3: Protein Adequacy Timeline
|
||||
const renderProteinAdequacy = () => {
|
||||
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Nicht genug Protein-Daten
|
||||
</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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}}/>
|
||||
<Line type="monotone" dataKey="targetLow" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="Ziel Min" dot={false}/>
|
||||
<Line type="monotone" dataKey="targetHigh" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="Ziel Max" dot={false}/>
|
||||
<Line type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} name="Protein (g)" dot={{r:2}}/>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// E5: Nutrition Consistency (Bar)
|
||||
const renderConsistency = () => {
|
||||
if (!consistencyData || consistencyData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Nicht genug Daten für Konsistenz-Analyse
|
||||
</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="name" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||
interval={0} angle={-45} textAnchor="end" height={60}/>
|
||||
<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}}/>
|
||||
<Bar dataKey="score" name="Score">
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Gesamt-Score: {consistencyData.metadata.consistency_score}/100
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartCard title="📊 Energiebilanz" loading={loading.energy} error={errors.energy}>
|
||||
{renderEnergyBalance()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Makronährstoff-Verteilung" loading={loading.macro} error={errors.macro}>
|
||||
{renderMacroDistribution()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Protein-Adequacy" loading={loading.protein} error={errors.protein}>
|
||||
{renderProteinAdequacy()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Ernährungs-Konsistenz" loading={loading.consistency} error={errors.consistency}>
|
||||
{renderConsistency()}
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
320
frontend/src/components/RecoveryCharts.jsx
Normal file
320
frontend/src/components/RecoveryCharts.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||||
{title}
|
||||
</div>
|
||||
{loading && (
|
||||
<div style={{display:'flex',justifyContent:'center',padding:40}}>
|
||||
<div className="spinner" style={{width:32,height:32}}/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Recovery-Daten vorhanden
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = recoveryData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
score: recoveryData.data.datasets[0]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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} domain={[0,100]}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Line type="monotone" dataKey="score" stroke="#1D9E75" strokeWidth={2} name="Recovery Score" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R2: HRV/RHR vs Baseline
|
||||
const renderHrvRhr = () => {
|
||||
if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Vitalwerte vorhanden
|
||||
</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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 yAxisId="left" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<YAxis yAxisId="right" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{r:2}}/>
|
||||
<Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R3: Sleep Duration + Quality
|
||||
const renderSleepQuality = () => {
|
||||
if (!sleepData || sleepData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Schlafdaten vorhanden
|
||||
</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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 yAxisId="left" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<YAxis yAxisId="right" orientation="right" 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}}/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{r:2}}/>
|
||||
<Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R4: Sleep Debt
|
||||
const renderSleepDebt = () => {
|
||||
if (!debtData || debtData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Schlafdaten für Schulden-Berechnung
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = debtData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
debt: debtData.data.datasets[0]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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}}/>
|
||||
<Line type="monotone" dataKey="debt" stroke="#EF4444" strokeWidth={2} name="Schlafschuld (h)" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Aktuelle Schuld: {debtData.metadata.current_debt_hours.toFixed(1)}h
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R5: Vital Signs Matrix (Bar)
|
||||
const renderVitalSigns = () => {
|
||||
if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine aktuellen Vitalwerte
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = vitalsData.data.labels.map((label, i) => ({
|
||||
name: label,
|
||||
value: vitalsData.data.datasets[0]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:20}} layout="horizontal">
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis type="number" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<YAxis type="category" dataKey="name" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} width={120}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Bar dataKey="value" fill="#1D9E75" name="Wert"/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Letzte {vitalsData.metadata.data_points} Messwerte (7 Tage)
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartCard title="📊 Recovery Score" loading={loading.recovery} error={errors.recovery}>
|
||||
{renderRecoveryScore()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 HRV & Ruhepuls" loading={loading.hrvRhr} error={errors.hrvRhr}>
|
||||
{renderHrvRhr()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Schlaf: Dauer & Qualität" loading={loading.sleep} error={errors.sleep}>
|
||||
{renderSleepQuality()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Schlafschuld" loading={loading.debt} error={errors.debt}>
|
||||
{renderSleepDebt()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Vitalwerte Überblick" loading={loading.vitals} error={errors.vitals}>
|
||||
{renderVitalSigns()}
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|||
<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>
|
||||
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -915,10 +924,32 @@ function PhotoGrid() {
|
|||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
// ── Recovery Section ──────────────────────────────────────────────────────────
|
||||
function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
const [period, setPeriod] = useState(28)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="😴 Erholung & Vitalwerte" to="/vitals" toLabel="Daten"/>
|
||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||
|
||||
<div style={{marginBottom:12,fontSize:13,color:'var(--text2)',lineHeight:1.6}}>
|
||||
Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick.
|
||||
</div>
|
||||
|
||||
{/* Recovery Charts (Phase 0c) */}
|
||||
<RecoveryCharts days={period === 9999 ? 90 : period} />
|
||||
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesundheit'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='recovery' && <RecoverySection {...sp}/>}
|
||||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||||
{tab==='photos' && <PhotoGrid/>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user