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..5880bfb 100644 --- a/backend/data_layer/recovery_interpretation.py +++ b/backend/data_layer/recovery_interpretation.py @@ -41,6 +41,8 @@ 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, + include_avg_sleep_kpi: bool = True, ) -> List[Dict[str, Any]]: tiles: List[Dict[str, Any]] = [] @@ -78,61 +80,92 @@ 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"], + } + ) - 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 +174,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 +203,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 b0f8be6..f04ea04 100644 --- a/backend/data_layer/recovery_viz.py +++ b/backend/data_layer/recovery_viz.py @@ -14,6 +14,7 @@ from data_layer.recovery_chart_payloads import ( build_sleep_duration_quality_chart_payload, build_vital_signs_matrix_chart_payload, ) +from data_layer.vitals_fitness_insights import build_vitals_history_and_analytics from data_layer.recovery_interpretation import ( build_recovery_dashboard_kpi_tiles, build_recovery_progress_insights, @@ -74,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( @@ -82,12 +84,18 @@ 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, 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 new file mode 100644 index 0000000..a751942 --- /dev/null +++ b/backend/data_layer/vitals_fitness_insights.py @@ -0,0 +1,400 @@ +""" +Vitalwerte: Zeitreihen + einfache Fitness-/Recovery-Einordnung (Layer 1, Issue 53). + +Keine Diagnose — deskriptive Trends, Korrelationen und Varianz-Hinweise. +""" + +from __future__ import annotations + +import statistics +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Sequence + +from db import get_db, get_cursor +from data_layer.utils import safe_float, serialize_dates + +SERIES_CONFIG = ( + ("resting_hr", "Ruhepuls", "bpm", "#3B82F6"), + ("hrv", "HRV", "ms", "#1D9E75"), + ("vo2_max", "VO2max", "ml/kg/min", "#8B5CF6"), + ("spo2", "SpO2", "%", "#0EA5E9"), + ("respiratory_rate", "Atemfrequenz", "/min", "#F59E0B"), +) + + +def _date_to_ord(d: Any) -> float: + if hasattr(d, "toordinal"): + return float(d.toordinal()) + if isinstance(d, str): + return float(datetime.fromisoformat(d[:10]).date().toordinal()) + return 0.0 + + +def _linear_slope(dates: Sequence[Any], values: Sequence[float]) -> float: + if len(values) < 3 or len(dates) != len(values): + return 0.0 + xs = [_date_to_ord(d) for d in dates] + ys = list(values) + n = len(xs) + mx = sum(xs) / n + my = sum(ys) / n + den = sum((x - mx) ** 2 for x in xs) + if den < 1e-9: + return 0.0 + return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den + + +def _pearson(xs: Sequence[float], ys: Sequence[float]) -> Optional[float]: + n = len(xs) + if n < 5 or len(ys) != n: + return None + mx = statistics.mean(xs) + my = statistics.mean(ys) + sx = statistics.pstdev(xs) if n > 1 else 0.0 + sy = statistics.pstdev(ys) if n > 1 else 0.0 + if sx < 1e-9 or sy < 1e-9: + return None + cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / n + return cov / (sx * sy) + + +def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: + """Summe Trainingsminuten pro Kalendertag als Belastungs-Proxy.""" + cur.execute( + """ + SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes + FROM activity_log + WHERE profile_id = %s AND date >= %s::date AND duration_min IS NOT NULL AND duration_min > 0 + GROUP BY date + ORDER BY date + """, + (profile_id, cutoff), + ) + rows = cur.fetchall() + 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 _de_num(x: float) -> str: + """Dezimalzahl mit Komma für Fließtext.""" + return f"{x:.1f}".replace(".", ",") + + +def _de_num_signed(x: float) -> str: + """Wie _de_num, mit explizitem Vorzeichen (für %-Abweichungen).""" + return f"{x:+.1f}".replace(".", ",") + + +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[Dict[str, Any]]: + """ + Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt. + section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf. + """ + out: List[Dict[str, Any]] = [] + + basis_bits: List[str] = [] + if hrv_vs_baseline_pct is not None: + basis_bits.append( + 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: {_de_num_signed(float(rhr_vs_baseline_pct))} %" + ) + if basis_bits: + 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") + + 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:]] + 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: + 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_body = ( + "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder " + "besserer Regeneration vereinbar (individuell)." + ) + r_short_tone = "good" + + rhr_var_sentence = "" + if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6: + rhr_var_sentence = ( + 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: + hrv_var_sentence = ( + f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — " + "Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte." + ) + + ma_hint = ( + "Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert " + "über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)." + ) + + streuung_parts: List[str] = [ma_hint] + if rhr_var_sentence: + streuung_parts.append(rhr_var_sentence) + if hrv_var_sentence: + 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)) + + 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: + 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: + 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", + ) + ) + + if r_pearson is not None and pairs_n >= 8: + if r_pearson > 0.35: + 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: + 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 out + + +def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: + cur.execute( + """ + SELECT date::text AS d, resting_hr::float AS rhr + FROM vitals_baseline + WHERE profile_id = %s AND date >= %s::date AND resting_hr IS NOT NULL + ORDER BY date + """, + (profile_id, cutoff), + ) + return {r["d"]: float(r["rhr"]) for r in cur.fetchall()} + + +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) + zusammengefasste Einordnung. + + Optional: Abweichung HRV/Ruhepuls zur älteren Basis — für einen Absatz statt doppelter KPI-Texte. + """ + if days < 7: + days = 7 + if days > 365: + days = 365 + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate + FROM vitals_baseline + WHERE profile_id = %s AND date >= %s + ORDER BY date ASC + """, + (profile_id, cutoff), + ) + rows = cur.fetchall() + + series: Dict[str, Any] = {} + for key, label_de, unit, color in SERIES_CONFIG: + pts: List[Dict[str, Any]] = [] + dates: List[Any] = [] + vals: List[float] = [] + for r in rows: + v = r.get(key) + if v is None: + continue + fv = safe_float(v) + d = r["date"] + d_iso = d.isoformat() if hasattr(d, "isoformat") else str(d)[:10] + pts.append({"date": d_iso, "value": round(fv, 2)}) + 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, + "stdev": round(statistics.pstdev(vals), 2) if len(vals) >= 2 else None, + "slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None, + } + + # Belastung (Activity) vs Ruhepuls am Folgetag + with get_db() as conn: + cur = get_cursor(conn) + load_by_d = _daily_training_load(cur, profile_id, cutoff) + rhr_by_d = _rhr_by_date(cur, profile_id, cutoff) + + pairs_load: List[float] = [] + pairs_rhr: List[float] = [] + for d_str, load_min in load_by_d.items(): + try: + d0 = datetime.fromisoformat(d_str[:10]).date() + except ValueError: + continue + d1 = (d0 + timedelta(days=1)).isoformat() + if d1 in rhr_by_d and load_min > 0: + pairs_load.append(load_min) + pairs_rhr.append(rhr_by_d[d1]) + + r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None + pairs_n = len(pairs_load) + + section_insights = _build_section_insights( + 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": [], + "consolidated_paragraphs": [], + "section_insights": section_insights, + }, + "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, + }, + } + + return { + "chart_type": "vitals_dashboard", + "window_days": days, + "series": serialize_dates(series), + "analytics": { + "bullets": [], + "consolidated_paragraphs": [], + "section_insights": section_insights, + }, + "metadata": { + "confidence": "medium", + "note": "Deskriptive Auswertung; keine medizinische Diagnose.", + "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 633f3e3..a45c3d8 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -1,17 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { - LineChart, - Line, - BarChart, - Bar, - Cell, - 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' @@ -19,25 +8,177 @@ import dayjs from 'dayjs' const fmtDate = (d) => dayjs(d).format('DD.MM.') -function vitalToneToUi(tone) { - if (tone === 'good') return 'good' - if (tone === 'bad') return 'bad' - if (tone === 'neutral') return 'neutral' - return 'warn' +/** 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() } -function barFillForTone(tone) { - const ui = vitalToneToUi(tone) - if (ui === 'good') return '#1D9E75' - if (ui === 'bad') return '#D85A30' - if (ui === 'neutral') return '#6B7280' - return '#EF9F27' +function insightBulletStripe(tone) { + if (tone === 'good') return getStatusColor('good') + if (tone === 'bad') return getStatusColor('bad') + if (tone === 'neutral') return '#6B7280' + return getStatusColor('warn') } -function ChartCard({ title, loading, error, children }) { +function SectionHeading({ title, hint, compactTop }) { + return ( +
+
{title}
+ {hint ? ( +
{hint}
+ ) : null} +
+ ) +} + +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 ( +
+ 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 (
-
{title}
+
{title}
+ {description ? ( +
{description}
+ ) : null} {loading && (
@@ -127,6 +268,17 @@ export default function RecoveryDashboardOverview({ const sleepData = viz.charts?.sleep_duration_quality const debtData = viz.charts?.sleep_debt const vitalsData = viz.charts?.vital_signs_matrix + const vitalsHistory = viz.charts?.vitals_history + + const vitalItemsByKey = {} + ;(vitalsData?.metadata?.vital_items || []).forEach((it) => { + vitalItemsByKey[it.key] = it + }) + + 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, @@ -163,7 +315,14 @@ export default function RecoveryDashboardOverview({ tickLine={false} interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} /> - + (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} + tickCount={6} + width={36} + /> - - + + { - if (!vitalsData) { + /** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */ + const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => { + const vh = vitalsHistory + if (!vh) { + return
Keine Verlaufs-Daten (Bundle).
+ } + if (vh.metadata?.confidence === 'insufficient') { return ( -
- Keine Vital-Matrix-Daten +
+ {vh.metadata?.message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.'}
) } - const meta = vitalsData.metadata || {} - const items = meta.vital_items || [] - const ds0 = vitalsData.data?.datasets?.[0] - const hasRawChart = - Array.isArray(vitalsData.data?.labels) && - vitalsData.data.labels.length > 0 && - Array.isArray(ds0?.data) && - ds0.data.length > 0 - const ins = meta.confidence === 'insufficient' - if (ins && items.length === 0 && !hasRawChart) { + const series = vh.series || {} + const keys = VITAL_TREND_ONLY_KEYS.filter((k) => series[k]?.points?.length) + if (keys.length === 0) { return ( -
- {meta.message || 'Keine aktuellen Vitalwerte'} +
+ Keine zusätzlichen Vital-Verläufe (VO2max, SpO2, Atemfrequenz) im Fenster — oder nur Ruhepuls/HRV erfasst.
) } - let chartRows = items.map((it) => ({ - name: it.label_de, - value: Number(it.bar_value ?? 0), - fill: barFillForTone(it.tone), - tone: it.tone, - })) - if (chartRows.length === 0 && hasRawChart) { - const bg = ds0.backgroundColor - chartRows = vitalsData.data.labels.map((name, i) => ({ - name, - value: Number(ds0.data[i] ?? 0), - fill: Array.isArray(bg) ? bg[i] || '#1D9E75' : bg || '#1D9E75', - tone: 'neutral', - })) - } - - if (items.length === 0 && chartRows.length === 0) { - return ( -
- Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten). -
- ) - } - - 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}
-
- ) - })} +
+ {vo2Insights.length > 0 ? ( +
+ {vo2Insights.map((ins) => ( + + ))}
) : null} - - {items.length === 0 && chartRows.length > 0 ? ( -
- Diagramm aus Server-Daten (ohne Zonen-Detail — bitte App aktualisieren oder Cache leeren). -
- ) : null} - - {chartRows.length > 0 ? ( - <> -
- Relative Einordnung (0–100, nur Übersicht — keine körperliche Messgröße) -
- - - - - - [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']} - /> - - {chartRows.map((row, i) => ( - - ))} - - - - - ) : null} - -
- {vitDate ? ( - <> - Baseline-Vitals Stand: {fmtDate(vitDate)} - - ) : null} - {vitDate && bpDate ? ' · ' : null} - {bpDate ? ( - <> - Blutdruck Stand: {fmtDate(bpDate)} - - ) : null} - {!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage : null} +
+ Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich + begrenzt.
- {disclaimer ? ( -
{disclaimer}
- ) : null} - + {keys.map((k) => { + const m = series[k] + const pts = m.points || [] + const maPts = m.points_ma7 || [] + const zoneItem = vitalItemsByKey[k] + const chartData = pts.map((p, i) => ({ + ...p, + d: fmtDate(p.date), + value_ma: maPts[i]?.value != null ? maPts[i].value : null, + })) + const vals = [] + pts.forEach((p, i) => { + vals.push(p.value) + if (maPts[i]?.value != null) vals.push(maPts[i].value) + }) + const mn = Math.min(...vals) + const mx = Math.max(...vals) + const span = mx - mn + const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : Math.max(span * 0.12, 0.01) + const hasMa = maPts.length > 0 && maPts.some((x) => x?.value != null) + + return ( +
+
+ {m.label_de} ({m.unit}) + {m.n != null ? · n = {m.n} : null} + {m.mean != null ? ( + · Ø {formatAxisTick(m.mean)} + ) : null} +
+ + {pts.length === 1 ? ( +
+ Ein Messpunkt ({formatAxisTick(m.last)}) — weiter erfassen, um einen Verlauf zu sehen. +
+ ) : ( +
+ + + + + + (value != null ? formatAxisTick(value) : '')} + /> + {hasMa ? : null} + + {hasMa ? ( + + ) : null} + + +
+ )} +
+ ) + })} +
) } @@ -504,43 +639,118 @@ export default function RecoveryDashboardOverview({ ) : null}
-

- Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. {eff} Tage · Charts{' '} - {cDays} Tage · Vital-Matrix {vDays} Tage. +

+ Daten-Layer Auswertung · Fenster ca. {eff} Tage · Chart-Horizont {cDays} Tage · + Vital-Snapshot {vDays} Tage.

- + {insights.length > 0 ? ( -
-
Einschätzungen
-
- {insights.map((ins) => ( -
-
{ins.title}
-
{ins.body}
-
- ))} +
+
+ Überblick: Recovery & Schlaf +
+
+ {insights.map((ins) => { + const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn' + return ( +
+
{ins.title}
+
{ins.body}
+
+ ) + })}
) : null} -
Diagramme
+ + + {renderRecoveryScore()} + + + {renderSleepQuality()} + + + {renderSleepDebt()} + - {renderRecoveryScore()} - {renderHrvRhr()} - {renderSleepQuality()} - {renderSleepDebt()} - {renderVitalSigns()} + +
+
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(vo2SectionInsights, vitalItemsByKey)} +
) }