feat: enhance recovery dashboard with optional average sleep KPI and structured insights
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s

- Added an `include_avg_sleep_kpi` parameter to the `build_recovery_dashboard_kpi_tiles` function to conditionally include average sleep data in the dashboard.
- Updated the `get_recovery_dashboard_viz_bundle` function to pass the new parameter, ensuring flexibility in data presentation.
- Refactored the insights generation in the `vitals_fitness_insights.py` file to utilize a new structured approach for better organization of heart and VO2 insights.
- Introduced new components in the frontend for displaying insights, improving the user experience and clarity of vital metrics.
This commit is contained in:
Lars 2026-04-20 11:43:56 +02:00
parent 857cc1043a
commit 61738cecb7
4 changed files with 291 additions and 258 deletions

View File

@ -42,6 +42,7 @@ def build_recovery_dashboard_kpi_tiles(
hrv_vs_baseline_pct: Optional[float], hrv_vs_baseline_pct: Optional[float],
rhr_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float],
merge_heart_autonomic_tiles: bool = True, merge_heart_autonomic_tiles: bool = True,
include_avg_sleep_kpi: bool = True,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
tiles: List[Dict[str, Any]] = [] tiles: List[Dict[str, Any]] = []
@ -79,20 +80,21 @@ def build_recovery_dashboard_kpi_tiles(
} }
) )
tiles.append( if include_avg_sleep_kpi:
{ tiles.append(
"key": "avg_sleep", {
"category": "Ø Schlafdauer", "key": "avg_sleep",
"icon": "🌙", "category": "Ø Schlafdauer",
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "", "icon": "🌙",
"sublabel": "Im gewählten Fenster", "value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "",
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn", "sublabel": "Im gewählten Fenster",
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis", "status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
"hoverTop": "Durchschnittliche Schlafdauer", "verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
"hoverBody": "get_sleep_duration_data", "hoverTop": "Durchschnittliche Schlafdauer",
"keys": ["sleep_duration_avg"], "hoverBody": "get_sleep_duration_data",
} "keys": ["sleep_duration_avg"],
) }
)
if merge_heart_autonomic_tiles and ( if merge_heart_autonomic_tiles and (
hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None

View File

@ -75,6 +75,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
avg_sleep, avg_sleep,
float(hrv_dev) if hrv_dev is not None else None, float(hrv_dev) if hrv_dev is not None else None,
float(rhr_dev) if rhr_dev is not None else None, float(rhr_dev) if rhr_dev is not None else None,
include_avg_sleep_kpi=False,
) )
insights = build_recovery_progress_insights( insights = build_recovery_progress_insights(

View File

@ -93,40 +93,56 @@ def _de_num_signed(x: float) -> str:
return f"{x:+.1f}".replace(".", ",") return f"{x:+.1f}".replace(".", ",")
def _build_consolidated_paragraphs( def _ins(
key: str,
section: str,
title_de: str,
body: str,
tone: str = "neutral",
) -> Dict[str, Any]:
"""Ein strukturierter Hinweis für UI-Platzierung (section: heart | vo2)."""
return {"key": key, "section": section, "title_de": title_de, "body": body, "tone": tone}
def _build_section_insights(
series: Dict[str, Any], series: Dict[str, Any],
hrv_vs_baseline_pct: Optional[float], hrv_vs_baseline_pct: Optional[float],
rhr_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float],
r_pearson: Optional[float], r_pearson: Optional[float],
pairs_n: int, pairs_n: int,
) -> List[str]: ) -> List[Dict[str, Any]]:
""" """
Thematisch zusammengeführte Absätze inhaltlich alle früheren Einzel-Karten (Bullets), Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt.
ohne die Aussagen zu streichen (Redundanz nur bei wörtlicher Doppelung vermeiden). section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf.
""" """
paras: List[str] = [] out: List[Dict[str, Any]] = []
# ── Referenzlage (HRV/Ruhepuls vs. ältere Basis), wie zuvor in KPI/Narrativ genutzt
basis_bits: List[str] = [] basis_bits: List[str] = []
if hrv_vs_baseline_pct is not None: if hrv_vs_baseline_pct is not None:
basis_bits.append( basis_bits.append(
f"HRV liegt gegenüber der älteren Referenz bei {_de_num_signed(float(hrv_vs_baseline_pct))} %" f"HRV gegenüber älterer Referenz: {_de_num_signed(float(hrv_vs_baseline_pct))} %"
) )
if rhr_vs_baseline_pct is not None: if rhr_vs_baseline_pct is not None:
basis_bits.append( basis_bits.append(
f"Ruhepuls relativ zur Referenz bei {_de_num_signed(float(rhr_vs_baseline_pct))} %" f"Ruhepuls relativ zur Referenz: {_de_num_signed(float(rhr_vs_baseline_pct))} %"
) )
if basis_bits: if basis_bits:
paras.append( out.append(
" ".join(basis_bits) _ins(
+ " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren." "heart_baseline",
"heart",
"Kurzfristiges Mittel vs. ältere Basis",
" ".join(basis_bits)
+ " — Vergleich letzter Tage zum älteren Referenzmittel; individuell interpretieren (keine Diagnose).",
"neutral",
)
) )
rhr = series.get("resting_hr") rhr = series.get("resting_hr")
hrv_s = series.get("hrv") hrv_s = series.get("hrv")
# ── Ruhepuls: letzte 7 Messungen vs. vorangehendes Fenster (wie frühere Karten) rhr_short_body = ""
rhr_short_compare = "" r_short_tone = "neutral"
if rhr and rhr.get("points") and len(rhr["points"]) >= 10: if rhr and rhr.get("points") and len(rhr["points"]) >= 10:
pts = rhr["points"] pts = rhr["points"]
last7 = [p["value"] for p in pts[-7:]] last7 = [p["value"] for p in pts[-7:]]
@ -136,88 +152,110 @@ def _build_consolidated_paragraphs(
mb = statistics.mean(before) mb = statistics.mean(before)
diff = m7 - mb diff = m7 - mb
if diff > 3: if diff > 3:
rhr_short_compare = ( rhr_short_body = (
f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — " f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — "
"kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen." "kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen."
) )
r_short_tone = "warn"
elif diff < -3: elif diff < -3:
rhr_short_compare = ( rhr_short_body = (
"Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder " "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder "
"besserer Regeneration vereinbar (individuell)." "besserer Regeneration vereinbar (individuell)."
) )
r_short_tone = "good"
# ── Streuung: frühere Schwellen n ≥ 6 für die ausführlichen Varianz-Hinweise
rhr_var_sentence = "" rhr_var_sentence = ""
if ( if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6:
rhr
and rhr.get("stdev") is not None
and rhr.get("n", 0) >= 6
):
rhr_var_sentence = ( rhr_var_sentence = (
f"Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen sind normal; " f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen "
"extreme Sprünge mit Kontext (Training, Schlaf) betrachten." "sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
) )
hrv_var_sentence = "" hrv_var_sentence = ""
if ( if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6:
hrv_s
and hrv_s.get("stdev") is not None
and hrv_s.get("n", 0) >= 6
):
hrv_var_sentence = ( hrv_var_sentence = (
f"HRV schwankt im Fenster (σ {_de_num(float(hrv_s['stdev']))} ms). " f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — "
"Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte." "Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte."
) )
# Gestrichelte Linie = gleitender Mittelwert (neuer Kontext, ergänzt nicht ersetzt)
ma_hint = ( ma_hint = (
"Einzelwerte können stark springen; die gestrichelte Linie im Diagramm zeigt einen gleitenden Mittelwert " "Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert "
"über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)." "über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)."
) )
block_b_parts: List[str] = [] streuung_parts: List[str] = [ma_hint]
if rhr_short_compare:
block_b_parts.append(rhr_short_compare)
if rhr_var_sentence: if rhr_var_sentence:
block_b_parts.append(rhr_var_sentence) streuung_parts.append(rhr_var_sentence)
if hrv_var_sentence: if hrv_var_sentence:
block_b_parts.append(hrv_var_sentence) streuung_parts.append(hrv_var_sentence)
if block_b_parts: if rhr or hrv_s:
paras.append(ma_hint + " " + " ".join(block_b_parts)) out.append(
elif series: _ins(
# Kein Kurzvergleich/keine σ-Sätze, aber mindestens eine Vital-Zeitreihe: MA-Hinweis (Diagramm) "heart_streuung_ma",
paras.append(ma_hint) "heart",
"Streuung & gleitender Mittelwert",
" ".join(streuung_parts),
"neutral",
)
)
if rhr_short_body:
out.append(_ins("heart_rhr_kurz", "heart", "Ruhepuls: Kurzvergleich", rhr_short_body, r_short_tone))
# ── VO2max: Wortlaut wie in den früheren Bullet-Karten
vo2 = series.get("vo2_max") vo2 = series.get("vo2_max")
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None: if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
s = vo2["slope_per_day"] s = vo2["slope_per_day"]
if s > 0.002: if s > 0.002:
paras.append( out.append(
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder " _ins(
"besserer Datenlage vereinbar." "vo2_trend_up",
"vo2",
"VO2max-Verlauf",
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
"besserer Datenlage vereinbar.",
"good",
)
) )
elif s < -0.002: elif s < -0.002:
paras.append( out.append(
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen " _ins(
"entstehen; Verlauf beobachten." "vo2_trend_down",
"vo2",
"VO2max-Verlauf",
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
"entstehen; Verlauf beobachten.",
"warn",
)
) )
# ── Belastung vs. Folge-Ruhepuls: frühere Formulierungen + r/n wo berechnet
if r_pearson is not None and pairs_n >= 8: if r_pearson is not None and pairs_n >= 8:
if r_pearson > 0.35: if r_pearson > 0.35:
paras.append( out.append(
"An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen " _ins(
"Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis). " "heart_load_rhr",
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren." "heart",
"Training und Folge-Ruhepuls",
(
"An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen "
"Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis). "
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren."
),
"warn",
)
) )
elif r_pearson < -0.25: elif r_pearson < -0.25:
paras.append( out.append(
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem " _ins(
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare." "heart_load_rhr_neg",
"heart",
"Training und Folge-Ruhepuls",
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem "
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare.",
"neutral",
)
) )
return [p for p in paras if p] return out
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
@ -318,7 +356,7 @@ def build_vitals_history_and_analytics(
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
pairs_n = len(pairs_load) pairs_n = len(pairs_load)
consolidated = _build_consolidated_paragraphs( section_insights = _build_section_insights(
series, series,
hrv_vs_baseline_pct, hrv_vs_baseline_pct,
rhr_vs_baseline_pct, rhr_vs_baseline_pct,
@ -331,7 +369,11 @@ def build_vitals_history_and_analytics(
"chart_type": "vitals_dashboard", "chart_type": "vitals_dashboard",
"window_days": days, "window_days": days,
"series": {}, "series": {},
"analytics": {"bullets": [], "consolidated_paragraphs": consolidated}, "analytics": {
"bullets": [],
"consolidated_paragraphs": [],
"section_insights": section_insights,
},
"metadata": { "metadata": {
"confidence": "insufficient", "confidence": "insufficient",
"message": "Keine Vital-Zeitreihen im Fenster", "message": "Keine Vital-Zeitreihen im Fenster",
@ -346,7 +388,8 @@ def build_vitals_history_and_analytics(
"series": serialize_dates(series), "series": serialize_dates(series),
"analytics": { "analytics": {
"bullets": [], "bullets": [],
"consolidated_paragraphs": consolidated, "consolidated_paragraphs": [],
"section_insights": section_insights,
}, },
"metadata": { "metadata": {
"confidence": "medium", "confidence": "medium",

View File

@ -58,13 +58,120 @@ function VitalZoneHint({ item }) {
lineHeight: 1.45, lineHeight: 1.45,
}} }}
> >
<span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Zuletzt (Snapshot):</span> <span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Letzte Einordnung (Snapshot):</span>
<span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span> <span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span>
<span style={{ marginLeft: 6 }}>{item.hint_de}</span> <span style={{ marginLeft: 6 }}>{item.hint_de}</span>
</div> </div>
) )
} }
/** 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>
)
}
function ChartCard({ title, loading, error, children, description }) { function ChartCard({ title, loading, error, children, description }) {
return ( return (
<div className="card" style={{ marginBottom: 12 }}> <div className="card" style={{ marginBottom: 12 }}>
@ -168,11 +275,10 @@ export default function RecoveryDashboardOverview({
vitalItemsByKey[it.key] = it vitalItemsByKey[it.key] = it
}) })
const showVitalNarrativeBlock = const sectionInsights = vitalsHistory?.analytics?.section_insights || []
vitalsHistory && const heartSectionInsights = sectionInsights.filter((s) => s.section === 'heart')
vitalsHistory.metadata?.confidence !== 'insufficient' && const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
(((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 || const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
(vitalsHistory.analytics?.bullets || []).length > 0))
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
...t, ...t,
@ -383,62 +489,8 @@ export default function RecoveryDashboardOverview({
) )
} }
/** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */ /** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */
const renderVitalBelastungNarrative = () => { const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => {
const vh = vitalsHistory
if (!vh || vh.metadata?.confidence === 'insufficient') return null
const paragraphs = vh.analytics?.consolidated_paragraphs || []
const bullets = vh.analytics?.bullets || []
const showParagraphs = paragraphs.length > 0
const showBulletsFallback = !showParagraphs && bullets.length > 0
if (!showParagraphs && !showBulletsFallback) return null
return (
<div style={{ marginBottom: 0 }}>
{showParagraphs ? (
<div
style={{
borderRadius: 8,
padding: '12px 14px',
border: '1px solid var(--border)',
borderLeft: '4px solid var(--accent)',
background: 'var(--surface2)',
fontSize: 12,
color: 'var(--text2)',
lineHeight: 1.55,
}}
>
{paragraphs.map((text, i) => (
<p key={i} style={{ margin: i === 0 ? 0 : '10px 0 0' }}>
{text}
</p>
))}
</div>
) : null}
{showBulletsFallback ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{bullets.map((b) => (
<div
key={b.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${insightBulletStripe(b.tone)}`,
background: 'var(--surface2)',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{b.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{b.body}</div>
</div>
))}
</div>
) : null}
</div>
)
}
/** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */
const renderWeitereVitalVerlaeufe = (vitalItemsByKey) => {
const vh = vitalsHistory const vh = vitalsHistory
if (!vh) { if (!vh) {
return <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div> return <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
@ -462,6 +514,13 @@ export default function RecoveryDashboardOverview({
return ( return (
<div style={{ width: '100%', minWidth: 0 }}> <div style={{ width: '100%', minWidth: 0 }}>
{vo2Insights.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{vo2Insights.map((ins) => (
<SectionInsightCard key={ins.key} ins={ins} />
))}
</div>
) : null}
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}> <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich
begrenzt. begrenzt.
@ -556,106 +615,6 @@ export default function RecoveryDashboardOverview({
) )
} }
const renderVitalSigns = () => {
if (!vitalsData) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Snapshot-Daten zur Vital-Matrix.
</div>
)
}
const meta = vitalsData.metadata || {}
const items = meta.vital_items || []
const ins = meta.confidence === 'insufficient'
if (ins && items.length === 0) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
{meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
</div>
)
}
const vitDate = meta.vitals_measured_at
const bpDate = meta.blood_pressure_measured_at
const disclaimer = meta.disclaimer_de
return (
<>
{items.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
{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>
) : null}
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
{vitDate ? (
<>
Baseline-Vitals (Snapshot): <strong>{fmtDate(vitDate)}</strong>
</>
) : null}
{vitDate && bpDate ? ' · ' : null}
{bpDate ? (
<>
Blutdruck: <strong>{fmtDate(bpDate)}</strong>
</>
) : null}
{!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage</> : null}
</div>
{disclaimer ? (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
) : null}
</>
)
}
return ( return (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}> <div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
@ -687,9 +646,11 @@ export default function RecoveryDashboardOverview({
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} /> <KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
{insights.length > 0 || showVitalNarrativeBlock ? ( {insights.length > 0 ? (
<div style={{ marginBottom: 18 }}> <div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Überblick: Recovery & Schlaf
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{insights.map((ins) => { {insights.map((ins) => {
const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn' const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn'
@ -709,14 +670,6 @@ export default function RecoveryDashboardOverview({
</div> </div>
) )
})} })}
{showVitalNarrativeBlock ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Vitalverlauf & Belastung (Text)
</div>
{renderVitalBelastungNarrative()}
</div>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@ -732,7 +685,14 @@ export default function RecoveryDashboardOverview({
> >
{renderRecoveryScore()} {renderRecoveryScore()}
</ChartCard> </ChartCard>
<ChartCard title="Schlaf: Dauer & Qualität" description="Dauer (h) und Qualitätsanteil (%) — eigene Achsen."> <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.'
}
>
{renderSleepQuality()} {renderSleepQuality()}
</ChartCard> </ChartCard>
<ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer."> <ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer.">
@ -741,29 +701,56 @@ export default function RecoveryDashboardOverview({
<SectionHeading <SectionHeading
title="Herz & Kreislauf" title="Herz & Kreislauf"
hint="Ruhepuls und HRV nur hier im kombinierten Verlauf — keine zweite Darstellung darunter." hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
/> />
<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>
<ChartCard <ChartCard
title="HRV & Ruhepuls" title="HRV & Ruhepuls — Zeitverlauf"
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleiche Tage wie oben." description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
> >
{renderHrvRhr()} {renderHrvRhr()}
</ChartCard> </ChartCard>
<SectionHeading <SectionHeading
title="Weitere Vitalparameter (Verlauf)" title="Weitere Vitalparameter (Verlauf)"
hint="VO2max, SpO2 und Atemfrequenz mit Zonen-Einordnung zum letzten Snapshot (farbig unter dem Titel)." hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
/> />
<div className="card" style={{ marginBottom: 12 }}> <div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
{renderWeitereVitalVerlaeufe(vitalItemsByKey)} {renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
</div> </div>
<SectionHeading
title="Aktuelle Messwerte & Zonen"
hint="Neueste Baseline-Vitals und Blutdruck im Fenster — gleiche Logik wie Vital-Seite (keine Diagnose)."
/>
<ChartCard title="Snapshot & Einordnung">{renderVitalSigns()}</ChartCard>
</div> </div>
) )
} }