diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py index 34d4f50..cc83848 100644 --- a/backend/data_layer/recovery_chart_payloads.py +++ b/backend/data_layer/recovery_chart_payloads.py @@ -7,7 +7,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1). from __future__ import annotations from datetime import datetime, timedelta -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Set from db import get_db, get_cursor from data_layer.recovery_metrics import ( @@ -396,8 +396,15 @@ def _tone_to_bar_value(tone: str) -> float: return {"good": 88.0, "warn": 52.0, "bad": 22.0, "neutral": 62.0}.get(tone, 55.0) -def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: - """Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1).""" +def build_vital_signs_matrix_chart_payload( + profile_id: str, + days: int, + omit_snapshot_keys: Optional[Set[str]] = None, +) -> Dict[str, Any]: + """Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1). + + omit_snapshot_keys: z. B. {'resting_hr','hrv'} wenn dieselbe Einordnung bereits im Vital-Verlauf steht. + """ if days < 7: days = 7 if days > 365: @@ -464,7 +471,11 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s if bp_row: bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")} - items = build_vital_items_from_rows(vitals_for_items, bp_for_items) + items = build_vital_items_from_rows( + vitals_for_items, bp_for_items, omit_keys=omit_snapshot_keys + ) + if not items and vitals_for_items and omit_snapshot_keys: + items = build_vital_items_from_rows(vitals_for_items, bp_for_items, omit_keys=None) if not items: return { diff --git a/backend/data_layer/recovery_interpretation.py b/backend/data_layer/recovery_interpretation.py index 8be9863..ae75071 100644 --- a/backend/data_layer/recovery_interpretation.py +++ b/backend/data_layer/recovery_interpretation.py @@ -41,6 +41,7 @@ def build_recovery_dashboard_kpi_tiles( avg_sleep_hours: Optional[float], hrv_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float], + merge_heart_autonomic_tiles: bool = True, ) -> List[Dict[str, Any]]: tiles: List[Dict[str, Any]] = [] @@ -93,46 +94,76 @@ def build_recovery_dashboard_kpi_tiles( } ) - h_s = ( - "good" - if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0 - else "warn" - if hrv_vs_baseline_pct is not None - else "warn" - ) - tiles.append( - { - "key": "hrv_baseline", - "category": "HRV vs. Basis", - "icon": "〰️", - "value": f"{hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + if merge_heart_autonomic_tiles and ( + hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None + ): + h_s = ( + "good" + if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0 + else "warn" if hrv_vs_baseline_pct is not None - else "—", - "sublabel": "Letzte 3 Tage vs. ältere Basis", - "status": h_s, - "verdict": _verdict(h_s), - "hoverTop": "Abweichung HRV vom Referenzmittel", - "hoverBody": "calculate_hrv_vs_baseline_pct", - "keys": ["hrv_vs_baseline"], - } - ) + else "warn" + ) + parts: List[str] = [] + if hrv_vs_baseline_pct is not None: + parts.append(f"HRV {hrv_vs_baseline_pct:+.1f} %".replace(".", ",")) + if rhr_vs_baseline_pct is not None: + parts.append(f"RHR {rhr_vs_baseline_pct:+.1f} %".replace(".", ",")) + tiles.append( + { + "key": "herz_autonom", + "category": "Herz & autonomes System", + "icon": "❤️🩹", + "value": " · ".join(parts) if parts else "—", + "sublabel": "HRV/Ruhepuls vs. Referenz (3-Tage-Mittel vs. ältere Basis)", + "status": h_s, + "verdict": _verdict(h_s), + "hoverTop": "HRV und Ruhepuls relativ zur persönlichen Basis", + "hoverBody": "calculate_hrv_vs_baseline_pct · calculate_rhr_vs_baseline_pct", + "keys": ["hrv_vs_baseline", "rhr_vs_baseline"], + } + ) + else: + h_s = ( + "good" + if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0 + else "warn" + if hrv_vs_baseline_pct is not None + else "warn" + ) + tiles.append( + { + "key": "hrv_baseline", + "category": "HRV vs. Basis", + "icon": "〰️", + "value": f"{hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + if hrv_vs_baseline_pct is not None + else "—", + "sublabel": "Letzte 3 Tage vs. ältere Basis", + "status": h_s, + "verdict": _verdict(h_s), + "hoverTop": "Abweichung HRV vom Referenzmittel", + "hoverBody": "calculate_hrv_vs_baseline_pct", + "keys": ["hrv_vs_baseline"], + } + ) - tiles.append( - { - "key": "rhr_baseline", - "category": "Ruhepuls vs. Basis", - "icon": "❤️", - "value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",") - if rhr_vs_baseline_pct is not None - else "—", - "sublabel": "Niedriger oft günstiger", - "status": "good", - "verdict": "Gut", - "hoverTop": "Abweichung Ruhepuls", - "hoverBody": "calculate_rhr_vs_baseline_pct", - "keys": ["rhr_vs_baseline"], - } - ) + tiles.append( + { + "key": "rhr_baseline", + "category": "Ruhepuls vs. Basis", + "icon": "❤️", + "value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",") + if rhr_vs_baseline_pct is not None + else "—", + "sublabel": "Niedriger oft günstiger", + "status": "good", + "verdict": "Gut", + "hoverTop": "Abweichung Ruhepuls", + "hoverBody": "calculate_rhr_vs_baseline_pct", + "keys": ["rhr_vs_baseline"], + } + ) return tiles @@ -141,7 +172,9 @@ def build_recovery_progress_insights( recovery_score: Optional[int], sleep_debt_hours: Optional[float], hrv_vs_baseline_pct: Optional[float], + include_autonomic_hrv_narrative: bool = False, ) -> List[Dict[str, Any]]: + """HRV-Basistext optional: steckt gebündelt im Vital-Verlauf (consolidated_paragraphs).""" out: List[Dict[str, Any]] = [] if recovery_score is not None: @@ -168,7 +201,7 @@ def build_recovery_progress_insights( } ) - if hrv_vs_baseline_pct is not None: + if include_autonomic_hrv_narrative and hrv_vs_baseline_pct is not None: tone = "good" if hrv_vs_baseline_pct >= 0 else "warn" out.append( { diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py index 558fb0a..34a8850 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -83,13 +83,22 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A float(hrv_dev) if hrv_dev is not None else None, ) + hrv_f = float(hrv_dev) if hrv_dev is not None else None + rhr_f = float(rhr_dev) if rhr_dev is not None else None + charts = { "recovery_score": build_recovery_score_chart_payload(profile_id, chart_days), "hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days), "sleep_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days), "sleep_debt": build_sleep_debt_chart_payload(profile_id, chart_days), - "vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days), - "vitals_history": build_vitals_history_and_analytics(profile_id, vital_days), + "vital_signs_matrix": build_vital_signs_matrix_chart_payload( + profile_id, + vital_days, + omit_snapshot_keys={"resting_hr", "hrv"}, + ), + "vitals_history": build_vitals_history_and_analytics( + profile_id, vital_days, hrv_vs_baseline_pct=hrv_f, rhr_vs_baseline_pct=rhr_f + ), } conf = "medium" diff --git a/backend/data_layer/vital_signs_assessment.py b/backend/data_layer/vital_signs_assessment.py index 5339e4b..be9da0b 100644 --- a/backend/data_layer/vital_signs_assessment.py +++ b/backend/data_layer/vital_signs_assessment.py @@ -5,7 +5,7 @@ Keine Diagnose — typische Referenzbereiche für UI/Coaching. from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from data_layer.utils import safe_float @@ -104,20 +104,23 @@ def assess_vo2_max(value: float) -> tuple: def build_vital_items_from_rows( vitals_row: Optional[Dict[str, Any]], bp_row: Optional[Dict[str, Any]], + omit_keys: Optional[Set[str]] = None, ) -> List[Dict[str, Any]]: + """omit_keys: z. B. {'resting_hr','hrv'} wenn Einordnung zentral im Herz-/Autonomie-Block steht.""" + skip = omit_keys or set() items: List[Dict[str, Any]] = [] order = 0 if vitals_row: rhr = vitals_row.get("resting_hr") - if rhr is not None: + if rhr is not None and "resting_hr" not in skip: v = safe_float(rhr) t, z, h = assess_resting_hr(v) items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order)) order += 1 hrv = vitals_row.get("hrv") - if hrv is not None: + if hrv is not None and "hrv" not in skip: v = safe_float(hrv) t, z, h = assess_hrv_ms(v) items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order)) diff --git a/backend/data_layer/vitals_fitness_insights.py b/backend/data_layer/vitals_fitness_insights.py index d75c1c8..964a3af 100644 --- a/backend/data_layer/vitals_fitness_insights.py +++ b/backend/data_layer/vitals_fitness_insights.py @@ -8,7 +8,7 @@ from __future__ import annotations import statistics from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence from db import get_db, get_cursor from data_layer.utils import safe_float, serialize_dates @@ -74,6 +74,94 @@ def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, fl return {r["d"]: float(r["minutes"]) for r in rows} +def _trailing_window_means(vals: List[float], window: int = 7) -> List[float]: + """Gleitender Mittelwert über die letzten bis zu `window` aufeinanderfolgenden Messungen (nicht Kalendertage).""" + out: List[float] = [] + for i in range(len(vals)): + chunk = vals[max(0, i - window + 1) : i + 1] + out.append(round(statistics.mean(chunk), 2)) + return out + + +def _build_consolidated_paragraphs( + 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]: + """Eine zusammenhängende Einordnung statt vieler einzelner Karten zu Puls/HRV/Basis.""" + paras: List[str] = [] + + basis_bits: List[str] = [] + if hrv_vs_baseline_pct is not None: + basis_bits.append( + f"HRV liegt gegenüber der älteren Referenz bei {hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + ) + if rhr_vs_baseline_pct is not None: + basis_bits.append( + f"Ruhepuls relativ zur Referenz bei {rhr_vs_baseline_pct:+.1f} %".replace(".", ",") + ) + if basis_bits: + paras.append( + " ".join(basis_bits) + + " (Vergleich kurzfristiges Mittel vs. ältere Basis — individuell interpretieren)." + ) + + rhr = series.get("resting_hr") + hrv_s = series.get("hrv") + var_bits: List[str] = [] + if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 3: + var_bits.append(f"Ruhepuls Schwankungsbreite im Fenster etwa σ = {rhr['stdev']} bpm") + if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 3: + var_bits.append(f"HRV etwa σ = {hrv_s['stdev']} ms") + if var_bits: + paras.append( + "Einzelwerte können stark springen; die gestrichelte Linie zeigt einen gleitenden Mittelwert (max. 7 Messungen). " + + "Im Fenster: " + "; ".join(var_bits) + "." + ) + + if rhr and rhr.get("points") and len(rhr["points"]) >= 10: + pts = rhr["points"] + last7 = [p["value"] for p in pts[-7:]] + before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else [] + if before: + m7 = statistics.mean(last7) + mb = statistics.mean(before) + diff = m7 - mb + if abs(diff) > 3: + paras.append( + f"Kurzfristig liegt der Ruhepuls im Mittel der letzten 7 Messungen " + f"{'über' if diff > 0 else 'unter'} dem vorherigen Fenster (Δ ca. {abs(diff):.1f} bpm) — Kontext: Belastung, Schlaf, Stress." + ) + + 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( + "VO2max steigt im gewählten Fenster tendenziell — oft mit Trainingsreiz oder stabilen Messungen vereinbar." + ) + elif s < -0.002: + paras.append( + "VO2max fällt im Fenster leicht — kann Pause, Krankheit oder Messrauschen widerspiegeln." + ) + + if r_pearson is not None and pairs_n >= 8: + if r_pearson > 0.35: + paras.append( + f"Korrelation Trainingsminuten (Tag) → Ruhepuls (Folgetag): r ≈ {r_pearson:.2f} (n = {pairs_n} Paare). " + "Höhere Belastung und etwas höherer Ruhepuls am nächsten Morgen kommen in den Daten häufig zusammen — kein Kausalbeweis." + ) + elif r_pearson < -0.25: + paras.append( + f"Zwischen Tages-Belastung und Folge-Ruhepuls zeigt sich ein leicht negatives Zusammenspiel (r ≈ {r_pearson:.2f}, n = {pairs_n}). " + "Stark von Ausreißern und Datenlücken abhängig." + ) + + return [p for p in paras if p] + + def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: cur.execute( """ @@ -87,9 +175,16 @@ def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: return {r["d"]: float(r["rhr"]) for r in cur.fetchall()} -def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, Any]: +def build_vitals_history_and_analytics( + profile_id: str, + days: int, + hrv_vs_baseline_pct: Optional[float] = None, + rhr_vs_baseline_pct: Optional[float] = None, +) -> Dict[str, Any]: """ - Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + Kurz-Analytik. + Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + zusammengefasste Einordnung. + + Optional: Abweichung HRV/Ruhepuls zur älteren Basis — für einen Absatz statt doppelter KPI-Texte. """ if days < 7: days = 7 @@ -126,12 +221,17 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, dates.append(d) vals.append(fv) if pts: + ma_vals = _trailing_window_means(vals, window=7) + points_ma7 = [ + {"date": pts[i]["date"], "value": ma_vals[i]} for i in range(len(pts)) + ] series[key] = { "key": key, "label_de": label_de, "unit": unit, "color": color, "points": pts, + "points_ma7": points_ma7, "n": len(pts), "last": vals[-1] if vals else None, "mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None, @@ -139,82 +239,6 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, "slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None, } - bullets: List[Dict[str, Any]] = [] - - # VO2max-Trend - 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: - bullets.append( - { - "key": "vo2_trend_up", - "tone": "good", - "title": "VO2max-Verlauf", - "body": "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder besserer Datenlage vereinbar.", - } - ) - elif s < -0.002: - bullets.append( - { - "key": "vo2_trend_down", - "tone": "warn", - "title": "VO2max-Verlauf", - "body": "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen entstehen; Verlauf beobachten.", - } - ) - - # Ruhepuls: letzte 7 vs davor (wenn genug Punkte) - rhr = series.get("resting_hr") - if rhr and rhr.get("points"): - pts = rhr["points"] - if len(pts) >= 10: - last7 = [p["value"] for p in pts[-7:]] - before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else [] - if before: - m7 = statistics.mean(last7) - mb = statistics.mean(before) - diff = m7 - mb - if diff > 3: - bullets.append( - { - "key": "rhr_short_high", - "tone": "warn", - "title": "Ruhepuls zuletzt höher", - "body": f"Die letzten 7 Messungen liegen im Mittel ca. {diff:.1f} bpm über dem vorangehenden Fenster — kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen.", - } - ) - elif diff < -3: - bullets.append( - { - "key": "rhr_short_low", - "tone": "good", - "title": "Ruhepuls zuletzt niedriger", - "body": "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder besserer Regeneration vereinbar (individuell).", - } - ) - if rhr.get("stdev") is not None and rhr["n"] >= 6: - bullets.append( - { - "key": "rhr_var", - "tone": "neutral", - "title": "Schwankung Ruhepuls", - "body": f"Standardabweichung im Fenster ca. {rhr['stdev']} bpm — kurzfristige Schwankungen sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten.", - } - ) - - # HRV: Varianz-Hinweis - hrv_s = series.get("hrv") - if hrv_s and hrv_s.get("stdev") and hrv_s["n"] >= 6: - bullets.append( - { - "key": "hrv_var", - "tone": "neutral", - "title": "HRV-Schwankung", - "body": f"HRV schwankt im Fenster (σ ≈ {hrv_s['stdev']} ms). Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte.", - } - ) - # Belastung (Activity) vs Ruhepuls am Folgetag with get_db() as conn: cur = get_cursor(conn) @@ -234,35 +258,27 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, pairs_rhr.append(rhr_by_d[d1]) r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None - if r_pearson is not None: - if r_pearson > 0.35: - bullets.append( - { - "key": "load_rhr_pos", - "tone": "warn", - "title": "Belastung und Ruhepuls (Folgetag)", - "body": "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).", - } - ) - elif r_pearson < -0.25: - bullets.append( - { - "key": "load_rhr_neg", - "tone": "neutral", - "title": "Belastung und Ruhepuls", - "body": "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem Fenster — stark von Datenlage und Ausreißern abhängig.", - } - ) + pairs_n = len(pairs_load) + + consolidated = _build_consolidated_paragraphs( + series, + hrv_vs_baseline_pct, + rhr_vs_baseline_pct, + r_pearson, + pairs_n, + ) if not series: return { "chart_type": "vitals_dashboard", "window_days": days, "series": {}, - "analytics": {"bullets": []}, + "analytics": {"bullets": [], "consolidated_paragraphs": consolidated}, "metadata": { "confidence": "insufficient", "message": "Keine Vital-Zeitreihen im Fenster", + "load_rhr_pairs_n": pairs_n, + "load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None, }, } @@ -270,11 +286,14 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, "chart_type": "vitals_dashboard", "window_days": days, "series": serialize_dates(series), - "analytics": {"bullets": bullets}, + "analytics": { + "bullets": [], + "consolidated_paragraphs": consolidated, + }, "metadata": { "confidence": "medium", "note": "Deskriptive Auswertung; keine medizinische Diagnose.", - "load_rhr_pairs_n": len(pairs_load), + "load_rhr_pairs_n": pairs_n, "load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None, }, } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index c2d15f5..1a285fe 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -22,7 +22,7 @@ Version: 1.0 """ from fastapi import APIRouter, Depends, HTTPException, Query -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set from datetime import datetime, timedelta from auth import require_auth @@ -1432,11 +1432,18 @@ def get_sleep_debt_chart( @router.get("/vital-signs-matrix") def get_vital_signs_matrix_chart( days: int = Query(default=7, ge=7, le=365), - session: dict = Depends(require_auth) + omit_snapshot_keys: Optional[str] = Query( + default=None, + description="Optional: Komma-getrennte Keys ausblenden (z. B. resting_hr,hrv) wenn Einordnung woanders steht.", + ), + session: dict = Depends(require_auth), ) -> Dict: """Vital signs matrix (R5).""" profile_id = session["profile_id"] - return build_vital_signs_matrix_chart_payload(profile_id, days) + omit_set: Optional[Set[str]] = None + if omit_snapshot_keys and omit_snapshot_keys.strip(): + omit_set = {x.strip() for x in omit_snapshot_keys.split(",") if x.strip()} + return build_vital_signs_matrix_chart_payload(profile_id, days, omit_snapshot_keys=omit_set) # ── Correlation Charts ────────────────────────────────────────────────────── diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index bc02dd8..9fc1397 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' +import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts' import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor, getStatusBg } from '../utils/interpret' @@ -311,13 +311,39 @@ export default function RecoveryDashboardOverview({ } const series = vh.series || {} const keys = Object.keys(series) + const paragraphs = vh.analytics?.consolidated_paragraphs || [] const bullets = vh.analytics?.bullets || [] - const corrNote = vh.metadata?.load_rhr_correlation - const pairsN = vh.metadata?.load_rhr_pairs_n + const showParagraphs = paragraphs.length > 0 + const showBulletsFallback = !showParagraphs && bullets.length > 0 return (
+ {text} +
+ ))} ++ Ruhepuls und HRV sind in diesem Bereich bewusst nicht noch einmal als Zonen-Karten geführt — die Einordnung + steht oben im Vital-Verlauf und in der KPI-Kachel „Herz & autonomes System“. +
+ ) : null} {items.length > 0 ? (