diff --git a/backend/data_layer/recovery_interpretation.py b/backend/data_layer/recovery_interpretation.py index ae75071..5880bfb 100644 --- a/backend/data_layer/recovery_interpretation.py +++ b/backend/data_layer/recovery_interpretation.py @@ -42,6 +42,7 @@ def build_recovery_dashboard_kpi_tiles( hrv_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float], merge_heart_autonomic_tiles: bool = True, + include_avg_sleep_kpi: bool = True, ) -> List[Dict[str, Any]]: tiles: List[Dict[str, Any]] = [] @@ -79,20 +80,21 @@ def build_recovery_dashboard_kpi_tiles( } ) - tiles.append( - { - "key": "avg_sleep", - "category": "Ø Schlafdauer", - "icon": "🌙", - "value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—", - "sublabel": "Im gewählten Fenster", - "status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn", - "verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis", - "hoverTop": "Durchschnittliche Schlafdauer", - "hoverBody": "get_sleep_duration_data", - "keys": ["sleep_duration_avg"], - } - ) + if include_avg_sleep_kpi: + tiles.append( + { + "key": "avg_sleep", + "category": "Ø Schlafdauer", + "icon": "🌙", + "value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—", + "sublabel": "Im gewählten Fenster", + "status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn", + "verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis", + "hoverTop": "Durchschnittliche Schlafdauer", + "hoverBody": "get_sleep_duration_data", + "keys": ["sleep_duration_avg"], + } + ) if merge_heart_autonomic_tiles and ( hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index 8749e2b..f04ea04 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -75,6 +75,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A avg_sleep, float(hrv_dev) if hrv_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( diff --git a/backend/data_layer/vitals_fitness_insights.py b/backend/data_layer/vitals_fitness_insights.py index b64caa0..a751942 100644 --- a/backend/data_layer/vitals_fitness_insights.py +++ b/backend/data_layer/vitals_fitness_insights.py @@ -93,40 +93,56 @@ def _de_num_signed(x: float) -> str: 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], hrv_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float], r_pearson: Optional[float], pairs_n: int, -) -> List[str]: +) -> List[Dict[str, Any]]: """ - Thematisch zusammengeführte Absätze — inhaltlich alle früheren Einzel-Karten (Bullets), - ohne die Aussagen zu streichen (Redundanz nur bei wörtlicher Doppelung vermeiden). + Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt. + 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] = [] if hrv_vs_baseline_pct is not None: 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: 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: - paras.append( - " ".join(basis_bits) - + " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren." + out.append( + _ins( + "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") hrv_s = series.get("hrv") - # ── Ruhepuls: letzte 7 Messungen vs. vorangehendes Fenster (wie frühere Karten) - rhr_short_compare = "" + rhr_short_body = "" + r_short_tone = "neutral" if rhr and rhr.get("points") and len(rhr["points"]) >= 10: pts = rhr["points"] last7 = [p["value"] for p in pts[-7:]] @@ -136,88 +152,110 @@ def _build_consolidated_paragraphs( mb = statistics.mean(before) diff = m7 - mb 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 — " "kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen." ) + r_short_tone = "warn" elif diff < -3: - rhr_short_compare = ( + rhr_short_body = ( "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder " "besserer Regeneration vereinbar (individuell)." ) + r_short_tone = "good" - # ── Streuung: frühere Schwellen n ≥ 6 für die ausführlichen Varianz-Hinweise rhr_var_sentence = "" - if ( - rhr - and rhr.get("stdev") is not None - and rhr.get("n", 0) >= 6 - ): + if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6: rhr_var_sentence = ( - f"Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen sind normal; " - "extreme Sprünge mit Kontext (Training, Schlaf) betrachten." + f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen " + "sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten." ) hrv_var_sentence = "" - if ( - hrv_s - and hrv_s.get("stdev") is not None - and hrv_s.get("n", 0) >= 6 - ): + if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6: 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." ) - # Gestrichelte Linie = gleitender Mittelwert (neuer Kontext, ergänzt nicht ersetzt) 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)." ) - block_b_parts: List[str] = [] - if rhr_short_compare: - block_b_parts.append(rhr_short_compare) + streuung_parts: List[str] = [ma_hint] if rhr_var_sentence: - block_b_parts.append(rhr_var_sentence) + streuung_parts.append(rhr_var_sentence) if hrv_var_sentence: - block_b_parts.append(hrv_var_sentence) - if block_b_parts: - paras.append(ma_hint + " " + " ".join(block_b_parts)) - elif series: - # Kein Kurzvergleich/keine σ-Sätze, aber mindestens eine Vital-Zeitreihe: MA-Hinweis (Diagramm) - paras.append(ma_hint) + streuung_parts.append(hrv_var_sentence) + if rhr or hrv_s: + out.append( + _ins( + "heart_streuung_ma", + "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") if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None: s = vo2["slope_per_day"] if s > 0.002: - paras.append( - "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder " - "besserer Datenlage vereinbar." + out.append( + _ins( + "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: - paras.append( - "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen " - "entstehen; Verlauf beobachten." + out.append( + _ins( + "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 > 0.35: - paras.append( - "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." + out.append( + _ins( + "heart_load_rhr", + "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: - paras.append( - "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." + out.append( + _ins( + "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]: @@ -318,7 +356,7 @@ def build_vitals_history_and_analytics( r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None pairs_n = len(pairs_load) - consolidated = _build_consolidated_paragraphs( + section_insights = _build_section_insights( series, hrv_vs_baseline_pct, rhr_vs_baseline_pct, @@ -331,7 +369,11 @@ def build_vitals_history_and_analytics( "chart_type": "vitals_dashboard", "window_days": days, "series": {}, - "analytics": {"bullets": [], "consolidated_paragraphs": consolidated}, + "analytics": { + "bullets": [], + "consolidated_paragraphs": [], + "section_insights": section_insights, + }, "metadata": { "confidence": "insufficient", "message": "Keine Vital-Zeitreihen im Fenster", @@ -346,7 +388,8 @@ def build_vitals_history_and_analytics( "series": serialize_dates(series), "analytics": { "bullets": [], - "consolidated_paragraphs": consolidated, + "consolidated_paragraphs": [], + "section_insights": section_insights, }, "metadata": { "confidence": "medium", diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index d08781b..a45c3d8 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -58,13 +58,120 @@ function VitalZoneHint({ item }) { lineHeight: 1.45, }} > - Zuletzt (Snapshot): + Letzte Einordnung (Snapshot): {item.zone_label_de} {item.hint_de} ) } +/** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */ +function HeartAutonomicGuide() { + return ( +
+ + Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm + +
+

+ Es handelt sich um Abweichungen in % vom älteren Referenzmittel (kurzfristiges Mittel vs. längere + Basis) — nicht um absolute Normalwerte. +

+ +

+ Das Liniendiagramm zeigt die Rohverläufe; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert + anzeigen. +

+
+
+ ) +} + +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 ( +
+
{ins.title_de}
+
{ins.body}
+
+ ) +} + +function SnapshotCards({ items }) { + if (!items?.length) return null + return ( +
+ {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 ( +
+
+ {it.label_de} + {it.value_display} + + {it.zone_label_de} + +
+
{it.hint_de}
+
+ ) + })} +
+ ) +} + function ChartCard({ title, loading, error, children, description }) { return (
@@ -168,11 +275,10 @@ export default function RecoveryDashboardOverview({ vitalItemsByKey[it.key] = it }) - const showVitalNarrativeBlock = - vitalsHistory && - vitalsHistory.metadata?.confidence !== 'insufficient' && - (((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 || - (vitalsHistory.analytics?.bullets || []).length > 0)) + 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) const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ ...t, @@ -383,62 +489,8 @@ export default function RecoveryDashboardOverview({ ) } - /** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */ - const renderVitalBelastungNarrative = () => { - 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 ( -
- {showParagraphs ? ( -
- {paragraphs.map((text, i) => ( -

- {text} -

- ))} -
- ) : null} - {showBulletsFallback ? ( -
- {bullets.map((b) => ( -
-
{b.title}
-
{b.body}
-
- ))} -
- ) : null} -
- ) - } - - /** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */ - const renderWeitereVitalVerlaeufe = (vitalItemsByKey) => { + /** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */ + const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => { const vh = vitalsHistory if (!vh) { return
Keine Verlaufs-Daten (Bundle).
@@ -462,6 +514,13 @@ export default function RecoveryDashboardOverview({ return (
+ {vo2Insights.length > 0 ? ( +
+ {vo2Insights.map((ins) => ( + + ))} +
+ ) : null}
Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich begrenzt. @@ -556,106 +615,6 @@ export default function RecoveryDashboardOverview({ ) } - const renderVitalSigns = () => { - if (!vitalsData) { - return ( -
- Keine Snapshot-Daten zur Vital-Matrix. -
- ) - } - const meta = vitalsData.metadata || {} - const items = meta.vital_items || [] - const ins = meta.confidence === 'insufficient' - if (ins && items.length === 0) { - return ( -
- {meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'} -
- ) - } - - const vitDate = meta.vitals_measured_at - const bpDate = meta.blood_pressure_measured_at - const disclaimer = meta.disclaimer_de - - return ( - <> - {items.length > 0 ? ( -
- {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 ( -
-
- {it.label_de} - {it.value_display} - - {it.zone_label_de} - -
-
{it.hint_de}
-
- ) - })} -
- ) : null} - -
- {vitDate ? ( - <> - Baseline-Vitals (Snapshot): {fmtDate(vitDate)} - - ) : null} - {vitDate && bpDate ? ' · ' : null} - {bpDate ? ( - <> - Blutdruck: {fmtDate(bpDate)} - - ) : null} - {!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage : null} -
- {disclaimer ? ( -
{disclaimer}
- ) : null} - - ) - } - return (
@@ -687,9 +646,11 @@ export default function RecoveryDashboardOverview({ - {insights.length > 0 || showVitalNarrativeBlock ? ( + {insights.length > 0 ? (
-
Einschätzungen
+
+ Überblick: Recovery & Schlaf +
{insights.map((ins) => { const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn' @@ -709,14 +670,6 @@ export default function RecoveryDashboardOverview({
) })} - {showVitalNarrativeBlock ? ( -
-
- Vitalverlauf & Belastung (Text) -
- {renderVitalBelastungNarrative()} -
- ) : null}
) : null} @@ -732,7 +685,14 @@ export default function RecoveryDashboardOverview({ > {renderRecoveryScore()} - + {renderSleepQuality()} @@ -741,29 +701,56 @@ export default function RecoveryDashboardOverview({ +
+
Einordnung & Kontext
+ + {heartSectionInsights.length > 0 ? ( +
+ {heartSectionInsights.map((ins) => ( + + ))} +
+ ) : null} +
Letzte Messwerte (Zonen)
+ + {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( +
+ {vitalsData?.metadata?.vitals_measured_at ? ( + <> + Baseline-Vitals: {fmtDate(vitalsData.metadata.vitals_measured_at)} + + ) : null} + {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} + {vitalsData?.metadata?.blood_pressure_measured_at ? ( + <> + Blutdruck: {fmtDate(vitalsData.metadata.blood_pressure_measured_at)} + + ) : null} +
+ ) : null} + {vitalsData?.metadata?.disclaimer_de ? ( +
+ {vitalsData.metadata.disclaimer_de} +
+ ) : null} +
{renderHrvRhr()}
Verläufe
- {renderWeitereVitalVerlaeufe(vitalItemsByKey)} + {renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
- - - {renderVitalSigns()}
) }