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 (
+
+ 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.
+
+ Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm
+
+
+
+
- {text} -
- ))} -