- 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)
321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|