2026-04-20 08:11:23 +02:00
import { useState , useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
2026-04-20 10:29:43 +02:00
import { LineChart , Line , XAxis , YAxis , Tooltip , ResponsiveContainer , CartesianGrid , Legend } from 'recharts'
2026-04-20 08:11:23 +02:00
import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview'
2026-04-20 08:36:45 +02:00
import { getStatusColor , getStatusBg } from '../utils/interpret'
2026-04-20 08:11:23 +02:00
import dayjs from 'dayjs'
const fmtDate = ( d ) => dayjs ( d ) . format ( 'DD.MM.' )
2026-04-20 11:09:16 +02:00
/** Nur diese Kennzahlen als eigene Verläufe — Ruhepuls/HRV nur im kombinierten Diagramm (keine Doppelung). */
const VITAL _TREND _ONLY _KEYS = [ 'vo2_max' , 'spo2' , 'respiratory_rate' ]
function formatAxisTick ( v ) {
const n = Number ( v )
if ( ! Number . isFinite ( n ) ) return ''
const a = Math . abs ( n )
if ( a >= 100 ) return String ( Math . round ( n ) )
if ( a >= 10 ) return n . toFixed ( 1 )
return Number ( n . toFixed ( 2 ) ) . toString ( )
}
2026-04-20 09:36:10 +02:00
function insightBulletStripe ( tone ) {
if ( tone === 'good' ) return getStatusColor ( 'good' )
if ( tone === 'bad' ) return getStatusColor ( 'bad' )
if ( tone === 'neutral' ) return '#6B7280'
return getStatusColor ( 'warn' )
2026-04-20 08:36:45 +02:00
}
2026-04-20 11:09:16 +02:00
function SectionHeading ( { title , hint , compactTop } ) {
return (
< div style = { { marginTop : compactTop ? 8 : 20 , marginBottom : 10 } } >
< div style = { { fontSize : 13 , fontWeight : 600 , color : 'var(--text1)' } } > { title } < / div >
{ hint ? (
< div style = { { fontSize : 11 , color : 'var(--text3)' , marginTop : 4 , lineHeight : 1.45 } } > { hint } < / div >
) : null }
< / div >
)
}
function VitalZoneHint ( { item } ) {
if ( ! item ) return null
const stripe = insightBulletStripe ( item . tone )
const t = item . tone
const hintBg =
t === 'good' ? getStatusBg ( 'good' ) : t === 'bad' ? getStatusBg ( 'bad' ) : t === 'warn' ? getStatusBg ( 'warn' ) : 'var(--surface2)'
return (
< div
style = { {
marginBottom : 10 ,
padding : '8px 10px' ,
borderRadius : 8 ,
border : '1px solid var(--border)' ,
borderLeft : ` 4px solid ${ stripe } ` ,
background : hintBg ,
fontSize : 11 ,
color : 'var(--text2)' ,
lineHeight : 1.45 ,
} }
>
2026-04-20 11:43:56 +02:00
< span style = { { fontWeight : 600 , color : 'var(--text1)' , marginRight : 6 } } > Letzte Einordnung ( Snapshot ) : < / span >
2026-04-20 11:09:16 +02:00
< span style = { { fontWeight : 600 , color : stripe } } > { item . zone _label _de } < / span >
< span style = { { marginLeft : 6 } } > { item . hint _de } < / span >
< / div >
)
}
2026-04-20 11:43:56 +02:00
/** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */
function HeartAutonomicGuide ( ) {
return (
< details style = { { marginBottom : 12 , fontSize : 11 , color : 'var(--text2)' } } >
< summary style = { { cursor : 'pointer' , fontWeight : 600 , color : 'var(--text3)' , listStyle : 'none' } } >
Einordnungshilfe : KPI « Herz & autonomes System » & Diagramm
< / summary >
< div style = { { marginTop : 8 , lineHeight : 1.5 , paddingLeft : 2 } } >
< p style = { { margin : '0 0 8px' } } >
Es handelt sich um < strong > Abweichungen in % < / strong > vom älteren Referenzmittel ( kurzfristiges Mittel vs . längere
Basis ) — nicht um absolute Normalwerte .
< / p >
< ul style = { { margin : 0 , paddingLeft : 18 } } >
< li >
< strong > HRV < / strong > : Positive % = zuletzt oft über der älteren Basis ; sehr personenabhängig .
< / li >
< li >
< strong > Ruhepuls < / strong > : Negative % = niedriger als die Referenz ; bei Training oft unkritisch günstig .
< / li >
< / ul >
< p style = { { margin : '8px 0 0' } } >
Das Liniendiagramm zeigt die Rohverläufe ; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert
anzeigen .
< / p >
< / div >
< / details >
)
}
function SectionInsightCard ( { ins } ) {
const t = [ 'good' , 'warn' , 'bad' , 'neutral' ] . includes ( ins . tone ) ? ins . tone : 'neutral'
const stripe = insightBulletStripe ( t )
const bg =
t === 'good' ? getStatusBg ( 'good' ) : t === 'bad' ? getStatusBg ( 'bad' ) : t === 'warn' ? getStatusBg ( 'warn' ) : 'var(--surface2)'
return (
< div
style = { {
borderRadius : 8 ,
padding : '10px 12px' ,
border : '1px solid var(--border)' ,
borderLeft : ` 4px solid ${ stripe } ` ,
background : bg ,
} }
>
< div style = { { fontSize : 12 , fontWeight : 600 , marginBottom : 4 , color : 'var(--text1)' } } > { ins . title _de } < / div >
< div style = { { fontSize : 12 , color : 'var(--text2)' , lineHeight : 1.45 } } > { ins . body } < / div >
< / div >
)
}
function SnapshotCards ( { items } ) {
if ( ! items ? . length ) return null
return (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 10 , marginBottom : 12 } } >
{ items . map ( ( it ) => {
const stripe =
it . tone === 'good'
? getStatusColor ( 'good' )
: it . tone === 'bad'
? getStatusColor ( 'bad' )
: it . tone === 'warn'
? getStatusColor ( 'warn' )
: '#6B7280'
const bg =
it . tone === 'good'
? getStatusBg ( 'good' )
: it . tone === 'bad'
? getStatusBg ( 'bad' )
: it . tone === 'warn'
? getStatusBg ( 'warn' )
: 'var(--surface2)'
return (
< div
key = { it . key }
style = { {
borderRadius : 8 ,
padding : '10px 12px' ,
border : '1px solid var(--border)' ,
borderLeft : ` 4px solid ${ stripe } ` ,
background : bg ,
} }
>
< div style = { { display : 'flex' , flexWrap : 'wrap' , alignItems : 'baseline' , gap : 8 , marginBottom : 4 } } >
< span style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text1)' } } > { it . label _de } < / span >
< span style = { { fontSize : 13 , fontWeight : 700 , color : 'var(--text1)' } } > { it . value _display } < / span >
< span
style = { {
fontSize : 11 ,
fontWeight : 600 ,
padding : '2px 8px' ,
borderRadius : 6 ,
background : 'var(--surface)' ,
color : stripe ,
border : ` 1px solid ${ stripe } ` ,
} }
>
{ it . zone _label _de }
< / span >
< / div >
< div style = { { fontSize : 11 , color : 'var(--text2)' , lineHeight : 1.45 } } > { it . hint _de } < / div >
< / div >
)
} ) }
< / div >
)
}
2026-04-20 11:09:16 +02:00
function ChartCard ( { title , loading , error , children , description } ) {
2026-04-20 08:11:23 +02:00
return (
< div className = "card" style = { { marginBottom : 12 } } >
2026-04-20 11:09:16 +02:00
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : description ? 4 : 8 } } > { title } < / div >
{ description ? (
< div style = { { fontSize : 10 , color : 'var(--text3)' , marginBottom : 8 , lineHeight : 1.4 } } > { description } < / div >
) : null }
2026-04-20 08:11:23 +02:00
{ 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 >
)
}
/ * *
* Layer 2 b : Erholung — ein Request GET / api / charts / recovery - dashboard - viz ( recovery _metrics ) .
* /
export default function RecoveryDashboardOverview ( {
period : periodProp ,
onPeriodChange ,
hidePeriodSelector = false ,
} ) {
const nav = useNavigate ( )
const [ internalPeriod , setInternalPeriod ] = useState ( 28 )
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
const period = controlled ? periodProp : internalPeriod
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
const [ viz , setViz ] = useState ( null )
const [ loading , setLoading ] = useState ( true )
const [ err , setErr ] = useState ( null )
useEffect ( ( ) => {
let cancelled = false
setLoading ( true )
setErr ( null )
api
. getRecoveryDashboardViz ( period )
. then ( ( v ) => {
if ( ! cancelled ) setViz ( v )
} )
. catch ( ( e ) => {
if ( ! cancelled ) setErr ( e . message || 'Laden fehlgeschlagen' )
} )
. finally ( ( ) => {
if ( ! cancelled ) setLoading ( false )
} )
return ( ) => {
cancelled = true
}
} , [ period ] )
if ( loading ) {
return (
< div className = "card section-gap" >
< div className = "card-title" > Erholung & Vitalwerte < / div >
< div className = "spinner" style = { { margin : 24 } } / >
< / div >
)
}
if ( err ) {
return (
< div className = "card section-gap" >
< div className = "card-title" > Erholung & Vitalwerte < / div >
< div style = { { color : 'var(--danger)' } } > { err } < / div >
< / div >
)
}
if ( ! viz ? . has _recovery _data ) {
return (
< div className = "card section-gap" >
< div className = "card-title" > Erholung & Vitalwerte < / div >
< p style = { { fontSize : 12 , color : 'var(--text3)' , lineHeight : 1.45 , marginBottom : 14 } } >
{ viz ? . message || 'Noch keine Schlaf- oder Vitaldaten.' } Sobald du Schlaf oder morgendliche Vitalwerte erfasst
oder importierst , erscheinen Auswertungen hier .
< / p >
< button type = "button" className = "btn btn-primary" onClick = { ( ) => nav ( '/vitals' ) } >
Zu Vitalwerten
< / button >
< / div >
)
}
const recoveryData = viz . charts ? . recovery _score
const hrvRhrData = viz . charts ? . hrv _rhr
const sleepData = viz . charts ? . sleep _duration _quality
const debtData = viz . charts ? . sleep _debt
const vitalsData = viz . charts ? . vital _signs _matrix
2026-04-20 09:36:10 +02:00
const vitalsHistory = viz . charts ? . vitals _history
2026-04-20 08:11:23 +02:00
2026-04-20 11:09:16 +02:00
const vitalItemsByKey = { }
; ( vitalsData ? . metadata ? . vital _items || [ ] ) . forEach ( ( it ) => {
vitalItemsByKey [ it . key ] = it
} )
2026-04-20 11:43:56 +02:00
const sectionInsights = vitalsHistory ? . analytics ? . section _insights || [ ]
const heartSectionInsights = sectionInsights . filter ( ( s ) => s . section === 'heart' )
const vo2SectionInsights = sectionInsights . filter ( ( s ) => s . section === 'vo2' )
const heartSnapshotItems = [ 'resting_hr' , 'hrv' , 'blood_pressure' ] . map ( ( k ) => vitalItemsByKey [ k ] ) . filter ( Boolean )
2026-04-20 11:09:16 +02:00
2026-04-20 08:11:23 +02:00
const kpiTiles = ( viz . kpi _tiles || [ ] ) . map ( ( t ) => ( {
... t ,
sublabel :
typeof t . sublabel === 'string' && t . sublabel . length > 42 ? ` ${ t . sublabel . slice ( 0 , 40 ) } … ` : t . sublabel ,
} ) )
const insights = viz . progress _insights || [ ]
const eff = viz . effective _window _days
const cDays = viz . chart _days _used
const vDays = viz . vital _matrix _days _used
const showPeriodDropdown = ! hidePeriodSelector && ! controlled
const renderRecoveryScore = ( ) => {
if ( ! recoveryData || recoveryData . metadata ? . confidence === 'insufficient' ) {
return (
< div style = { { padding : 20 , textAlign : 'center' , color : 'var(--text3)' , fontSize : 12 } } >
Keine Recovery - Daten im Fenster
< / 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 ) }
/ >
2026-04-20 11:09:16 +02:00
< YAxis
tick = { { fontSize : 9 , fill : 'var(--text3)' } }
tickLine = { false }
domain = { [ 0 , 100 ] }
tickFormatter = { ( v ) => ( Number . isFinite ( Number ( v ) ) ? String ( Math . round ( Number ( v ) ) ) : '' ) }
tickCount = { 6 }
width = { 36 }
/ >
2026-04-20 08:11:23 +02:00
< 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 >
< / >
)
}
const renderHrvRhr = ( ) => {
if ( ! hrvRhrData || hrvRhrData . metadata ? . confidence === 'insufficient' ) {
return (
< div style = { { padding : 20 , textAlign : 'center' , color : 'var(--text3)' , fontSize : 12 } } >
Keine Vitalwerte im Fenster
< / 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 ) }
/ >
2026-04-20 11:09:16 +02:00
< YAxis
yAxisId = "left"
tick = { { fontSize : 9 , fill : 'var(--text3)' } }
tickLine = { false }
tickFormatter = { formatAxisTick }
tickCount = { 6 }
width = { 44 }
/ >
< YAxis
yAxisId = "right"
orientation = "right"
tick = { { fontSize : 9 , fill : 'var(--text3)' } }
tickLine = { false }
tickFormatter = { formatAxisTick }
tickCount = { 6 }
width = { 44 }
/ >
2026-04-20 08:11:23 +02:00
< 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 >
< / >
)
}
const renderSleepQuality = ( ) => {
if ( ! sleepData || sleepData . metadata ? . confidence === 'insufficient' ) {
return (
< div style = { { padding : 20 , textAlign : 'center' , color : 'var(--text3)' , fontSize : 12 } } >
Keine Schlafdaten im Fenster
< / 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 >
< / >
)
}
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 ] ,
} ) )
const curDebt = debtData . metadata ? . current _debt _hours
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 : { curDebt != null ? Number ( curDebt ) . toFixed ( 1 ) : '—' } h
< / div >
< / >
)
}
2026-04-20 11:43:56 +02:00
/** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */
const renderWeitereVitalVerlaeufe = ( vo2Insights , vitalItemsByKey ) => {
2026-04-20 11:09:16 +02:00
const vh = vitalsHistory
if ( ! vh ) {
return < div style = { { padding : 12 , fontSize : 12 , color : 'var(--text3)' } } > Keine Verlaufs - Daten ( Bundle ) . < / div >
}
if ( vh . metadata ? . confidence === 'insufficient' ) {
return (
< div style = { { padding : 12 , fontSize : 12 , color : 'var(--text3)' , lineHeight : 1.5 } } >
{ vh . metadata ? . message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.' }
< / div >
)
}
const series = vh . series || { }
const keys = VITAL _TREND _ONLY _KEYS . filter ( ( k ) => series [ k ] ? . points ? . length )
if ( keys . length === 0 ) {
return (
< div style = { { padding : 8 , fontSize : 12 , color : 'var(--text3)' } } >
Keine zusätzlichen Vital - Verläufe ( VO2max , SpO2 , Atemfrequenz ) im Fenster — oder nur Ruhepuls / HRV erfasst .
2026-04-20 09:36:10 +02:00
< / div >
2026-04-20 11:09:16 +02:00
)
}
2026-04-20 09:36:10 +02:00
2026-04-20 11:09:16 +02:00
return (
< div style = { { width : '100%' , minWidth : 0 } } >
2026-04-20 11:43:56 +02:00
{ vo2Insights . length > 0 ? (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 8 , marginBottom : 14 } } >
{ vo2Insights . map ( ( ins ) => (
< SectionInsightCard key = { ins . key } ins = { ins } / >
) ) }
< / div >
) : null }
2026-04-20 11:09:16 +02:00
< div style = { { fontSize : 11 , color : 'var(--text3)' , marginBottom : 10 } } >
Gestrichelte Linie : gleitender Mittelwert ( max . 7 aufeinanderfolgende Messungen ) . Y - Achse auf den Datenbereich
begrenzt .
< / div >
2026-04-20 09:36:10 +02:00
{ keys . map ( ( k ) => {
const m = series [ k ]
const pts = m . points || [ ]
2026-04-20 10:29:43 +02:00
const maPts = m . points _ma7 || [ ]
2026-04-20 11:09:16 +02:00
const zoneItem = vitalItemsByKey [ k ]
2026-04-20 10:29:43 +02:00
const chartData = pts . map ( ( p , i ) => ( {
2026-04-20 09:36:10 +02:00
... p ,
d : fmtDate ( p . date ) ,
2026-04-20 10:29:43 +02:00
value _ma : maPts [ i ] ? . value != null ? maPts [ i ] . value : null ,
2026-04-20 09:36:10 +02:00
} ) )
2026-04-20 10:29:43 +02:00
const vals = [ ]
pts . forEach ( ( p , i ) => {
vals . push ( p . value )
if ( maPts [ i ] ? . value != null ) vals . push ( maPts [ i ] . value )
} )
2026-04-20 09:36:10 +02:00
const mn = Math . min ( ... vals )
const mx = Math . max ( ... vals )
const span = mx - mn
2026-04-20 11:09:16 +02:00
const pad = span < 1 e - 9 ? Math . max ( Math . abs ( mn ) * 0.05 , 0.5 ) : Math . max ( span * 0.12 , 0.01 )
2026-04-20 10:29:43 +02:00
const hasMa = maPts . length > 0 && maPts . some ( ( x ) => x ? . value != null )
2026-04-20 09:36:10 +02:00
return (
2026-04-20 11:09:16 +02:00
< div key = { k } style = { { marginBottom : 18 , width : '100%' } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text2)' , marginBottom : 6 } } >
2026-04-20 09:36:10 +02:00
{ m . label _de } ( { m . unit } )
2026-04-20 11:09:16 +02:00
{ m . n != null ? < span style = { { fontWeight : 400 , color : 'var(--text3)' } } > · n = { m . n } < / span > : null }
2026-04-20 09:36:10 +02:00
{ m . mean != null ? (
2026-04-20 11:09:16 +02:00
< span style = { { fontWeight : 400 , color : 'var(--text3)' } } > · Ø { formatAxisTick ( m . mean ) } < / span >
2026-04-20 09:36:10 +02:00
) : null }
< / div >
2026-04-20 11:09:16 +02:00
< VitalZoneHint item = { zoneItem } / >
2026-04-20 09:36:10 +02:00
{ pts . length === 1 ? (
< div style = { { fontSize : 12 , color : 'var(--text3)' , padding : '8px 0' } } >
2026-04-20 11:09:16 +02:00
Ein Messpunkt ( { formatAxisTick ( m . last ) } ) — weiter erfassen , um einen Verlauf zu sehen .
2026-04-20 09:36:10 +02:00
< / div >
) : (
2026-04-20 10:29:43 +02:00
< div style = { { width : '100%' , height : hasMa ? 220 : 200 , minHeight : hasMa ? 220 : 200 } } >
2026-04-20 09:36:10 +02:00
< ResponsiveContainer width = "100%" height = "100%" >
< LineChart data = { chartData } margin = { { top : 4 , right : 12 , bottom : 0 , left : 0 } } >
< CartesianGrid stroke = "var(--border)" strokeDasharray = "3 3" / >
< XAxis dataKey = "d" tick = { { fontSize : 9 , fill : 'var(--text3)' } } interval = "preserveStartEnd" / >
< YAxis
domain = { [ mn - pad , mx + pad ] }
tick = { { fontSize : 9 , fill : 'var(--text3)' } }
2026-04-20 11:09:16 +02:00
tickFormatter = { formatAxisTick }
tickCount = { 6 }
width = { 48 }
2026-04-20 09:36:10 +02:00
/ >
< Tooltip
contentStyle = { {
background : 'var(--surface)' ,
border : '1px solid var(--border)' ,
borderRadius : 8 ,
fontSize : 11 ,
} }
2026-04-20 11:09:16 +02:00
formatter = { ( value ) => ( value != null ? formatAxisTick ( value ) : '' ) }
2026-04-20 09:36:10 +02:00
/ >
2026-04-20 10:29:43 +02:00
{ hasMa ? < Legend wrapperStyle = { { fontSize : 10 , paddingTop : 4 } } / > : null }
2026-04-20 09:36:10 +02:00
< Line
type = "monotone"
dataKey = "value"
stroke = { m . color || '#1D9E75' }
strokeWidth = { 2 }
dot = { { r : 3 } }
2026-04-20 10:29:43 +02:00
name = "Messwert"
2026-04-20 09:36:10 +02:00
/ >
2026-04-20 10:29:43 +02:00
{ hasMa ? (
< Line
type = "monotone"
dataKey = "value_ma"
stroke = { m . color || '#1D9E75' }
strokeOpacity = { 0.55 }
strokeWidth = { 2 }
strokeDasharray = "5 5"
dot = { false }
name = "Ø (max. 7 Messungen)"
connectNulls
/ >
) : null }
2026-04-20 09:36:10 +02:00
< / LineChart >
< / ResponsiveContainer >
< / div >
) }
< / div >
)
} ) }
< / div >
)
}
2026-04-20 08:11:23 +02:00
return (
< div className = "card section-gap" >
< div className = "card-title" style = { { display : 'flex' , flexWrap : 'wrap' , alignItems : 'center' , gap : 12 } } >
< span > Erholung & Vitalwerte < / span >
{ showPeriodDropdown ? (
< label
style = { { fontSize : 12 , fontWeight : 500 , color : 'var(--text3)' , display : 'flex' , alignItems : 'center' , gap : 8 } }
>
Zeitraum
< select
className = "form-input"
style = { { maxWidth : 140 , padding : '6px 10px' , fontSize : 13 } }
value = { period }
onChange = { ( e ) => setPeriod ( Number ( e . target . value ) ) }
>
< option value = { 7 } > 7 Tage < / option >
< option value = { 28 } > 28 Tage < / option >
< option value = { 90 } > 90 Tage < / option >
< option value = { 9999 } > Gesamt < / option >
< / select >
< / label >
) : null }
< / div >
2026-04-20 11:09:16 +02:00
< p style = { { fontSize : 11 , color : 'var(--text3)' , lineHeight : 1.45 , marginBottom : 12 } } >
Daten - Layer Auswertung · Fenster ca . < strong > { eff } < / strong > Tage · Chart - Horizont < strong > { cDays } < / strong > Tage ·
Vital - Snapshot < strong > { vDays } < / strong > Tage .
2026-04-20 08:11:23 +02:00
< / p >
2026-04-20 11:09:16 +02:00
< KpiTilesOverview tiles = { kpiTiles } heading = "Kennzahlen" marginBottom = { 16 } / >
2026-04-20 08:11:23 +02:00
2026-04-20 11:43:56 +02:00
{ insights . length > 0 ? (
2026-04-20 11:09:16 +02:00
< div style = { { marginBottom : 18 } } >
2026-04-20 11:43:56 +02:00
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 8 } } >
Überblick : Recovery & Schlaf
< / div >
2026-04-20 11:09:16 +02:00
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 10 } } >
{ insights . map ( ( ins ) => {
const t = [ 'good' , 'warn' , 'bad' ] . includes ( ins . tone ) ? ins . tone : 'warn'
return (
< div
key = { ins . key }
style = { {
borderRadius : 8 ,
padding : '10px 12px' ,
border : '1px solid var(--border)' ,
borderLeft : ` 4px solid ${ getStatusColor ( t ) } ` ,
background : getStatusBg ( t ) ,
} }
>
< div style = { { fontSize : 12 , fontWeight : 600 , marginBottom : 4 } } > { ins . title } < / div >
< div style = { { fontSize : 12 , color : 'var(--text2)' , lineHeight : 1.45 } } > { ins . body } < / div >
< / div >
)
} ) }
2026-04-20 08:11:23 +02:00
< / div >
< / div >
) : null }
2026-04-20 11:09:16 +02:00
< SectionHeading
compactTop
title = "Schlaf & Erholung"
hint = "Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
/ >
< ChartCard
title = "Recovery Score"
description = "0– 100, Verlauf im Chart-Fenster. Höher ist in der Regel günstiger."
>
{ renderRecoveryScore ( ) }
< / ChartCard >
2026-04-20 11:43:56 +02:00
< ChartCard
title = "Schlaf: Dauer & Qualität"
description = {
sleepData && sleepData . metadata ? . confidence !== 'insufficient' && sleepData . metadata ? . avg _duration _hours != null
? ` Dauer (h) und Qualitätsanteil (%). Mittlere Schlafdauer im Chart-Fenster: ${ sleepData . metadata . avg _duration _hours } h — gleiche Information wie früher in der KPI «Ø Schlafdauer», jetzt hier im Schlaf-Kontext. `
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
}
>
2026-04-20 11:09:16 +02:00
{ renderSleepQuality ( ) }
< / ChartCard >
< ChartCard title = "Schlafschuld" description = "Kumulierte Differenz zur Zielschlafdauer." >
{ renderSleepDebt ( ) }
< / ChartCard >
< SectionHeading
title = "Herz & Kreislauf"
2026-04-20 11:43:56 +02:00
hint = "Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
2026-04-20 11:09:16 +02:00
/ >
2026-04-20 11:43:56 +02:00
< div className = "card" style = { { marginBottom : 12 } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 8 } } > Einordnung & Kontext < / div >
< HeartAutonomicGuide / >
{ heartSectionInsights . length > 0 ? (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 8 , marginBottom : 12 } } >
{ heartSectionInsights . map ( ( ins ) => (
< SectionInsightCard key = { ins . key } ins = { ins } / >
) ) }
< / div >
) : null }
< div style = { { fontSize : 11 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 6 } } > Letzte Messwerte ( Zonen ) < / div >
< SnapshotCards items = { heartSnapshotItems } / >
{ vitalsData ? . metadata ? . vitals _measured _at || vitalsData ? . metadata ? . blood _pressure _measured _at ? (
< div style = { { fontSize : 10 , color : 'var(--text3)' , marginBottom : 8 , lineHeight : 1.45 } } >
{ vitalsData ? . metadata ? . vitals _measured _at ? (
< >
Baseline - Vitals : < strong > { fmtDate ( vitalsData . metadata . vitals _measured _at ) } < / strong >
< / >
) : null }
{ vitalsData ? . metadata ? . vitals _measured _at && vitalsData ? . metadata ? . blood _pressure _measured _at ? ' · ' : null }
{ vitalsData ? . metadata ? . blood _pressure _measured _at ? (
< >
Blutdruck : < strong > { fmtDate ( vitalsData . metadata . blood _pressure _measured _at ) } < / strong >
< / >
) : null }
< / div >
) : null }
{ vitalsData ? . metadata ? . disclaimer _de ? (
< div style = { { fontSize : 10 , color : 'var(--text3)' , fontStyle : 'italic' , marginBottom : 10 } } >
{ vitalsData . metadata . disclaimer _de }
< / div >
) : null }
< / div >
2026-04-20 11:09:16 +02:00
< ChartCard
2026-04-20 11:43:56 +02:00
title = "HRV & Ruhepuls — Zeitverlauf"
description = "Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
2026-04-20 11:09:16 +02:00
>
{ renderHrvRhr ( ) }
< / ChartCard >
< SectionHeading
title = "Weitere Vitalparameter (Verlauf)"
2026-04-20 11:43:56 +02:00
hint = "VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
2026-04-20 11:09:16 +02:00
/ >
< div className = "card" style = { { marginBottom : 12 } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 8 } } > Verläufe < / div >
2026-04-20 11:43:56 +02:00
{ renderWeitereVitalVerlaeufe ( vo2SectionInsights , vitalItemsByKey ) }
2026-04-20 11:09:16 +02:00
< / div >
2026-04-20 08:11:23 +02:00
< / div >
)
}