From f42d3a9c92420b667e7b0c5abd92047d57f700f3 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 08:11:23 +0200 Subject: [PATCH] feat: introduce recovery dashboard visualization and refactor recovery charts - Added a new endpoint for the recovery dashboard visualization in `charts.py`, integrating multiple recovery metrics and insights. - Implemented the `get_recovery_dashboard_viz` function to streamline data retrieval for recovery-related charts. - Refactored the `RecoveryCharts` component to utilize the new `RecoveryDashboardOverview`, simplifying the component structure and enhancing maintainability. - Updated the `RecoveryChartsPanelWidget` and `History` page to reflect the new recovery dashboard, improving user navigation and experience. - Deprecated the old recovery charts component, encouraging the use of the new overview for better data presentation. --- backend/data_layer/recovery_chart_payloads.py | 454 +++++++++++++++ backend/data_layer/recovery_interpretation.py | 183 ++++++ backend/data_layer/recovery_viz.py | 111 ++++ backend/routers/charts.py | 548 ++---------------- frontend/src/components/RecoveryCharts.jsx | 318 +--------- .../components/RecoveryDashboardOverview.jsx | 402 +++++++++++++ .../RecoveryChartsPanelWidget.jsx | 12 +- frontend/src/pages/History.jsx | 46 +- frontend/src/utils/api.js | 2 + 9 files changed, 1219 insertions(+), 857 deletions(-) create mode 100644 backend/data_layer/recovery_chart_payloads.py create mode 100644 backend/data_layer/recovery_interpretation.py create mode 100644 backend/data_layer/recovery_viz.py create mode 100644 frontend/src/components/RecoveryDashboardOverview.jsx diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py new file mode 100644 index 0000000..20c87f1 --- /dev/null +++ b/backend/data_layer/recovery_chart_payloads.py @@ -0,0 +1,454 @@ +""" +Chart.js-Payloads für Recovery (R1–R5) — gemeinsam mit routers/charts und recovery-dashboard-viz. + +Ausgelagert aus routers/charts.py (Issue 53 / Layer 1). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Dict + +from db import get_db, get_cursor +from data_layer.recovery_metrics import ( + calculate_hrv_vs_baseline_pct, + calculate_recovery_score_v2, + calculate_rhr_vs_baseline_pct, + calculate_sleep_debt_hours, + get_sleep_duration_data, + get_sleep_quality_data, +) +from data_layer.utils import calculate_confidence, safe_float, serialize_dates + + +def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + current_score = calculate_recovery_score_v2(profile_id) + + if current_score is None: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Recovery-Daten vorhanden", + }, + } + + 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 + FROM vitals_baseline + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": { + "labels": [datetime.now().strftime("%Y-%m-%d")], + "datasets": [ + { + "label": "Recovery Score", + "data": [current_score], + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "fill": True, + } + ], + }, + "metadata": { + "confidence": "low", + "data_points": 1, + "current_score": current_score, + }, + } + + labels = [row["date"].isoformat() for row in rows] + values = [min(100, max(0, safe_float(row["hrv"]) if row["hrv"] else 50)) for row in rows] + + return { + "chart_type": "line", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Recovery Score (proxy)", + "data": values, + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "fill": True, + } + ], + }, + "metadata": serialize_dates( + { + "confidence": calculate_confidence(len(rows), days, "general"), + "data_points": len(rows), + "current_score": current_score, + "note": "Score based on HRV proxy; true recovery score calculation in development", + } + ), + } + + +def build_hrv_rhr_baseline_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + 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 + FROM vitals_baseline + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Vitalwerte vorhanden", + }, + } + + labels = [row["date"].isoformat() for row in rows] + hrv_values = [safe_float(row["hrv"]) if row["hrv"] else None for row in rows] + rhr_values = [safe_float(row["resting_hr"]) if row["resting_hr"] else None for row in rows] + + hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id) + rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id) + + hrv_filtered = [v for v in hrv_values if v is not None] + rhr_filtered = [v for v in rhr_values if v is not None] + + avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50 + avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60 + + datasets = [ + { + "label": "HRV (ms)", + "data": hrv_values, + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y1", + "fill": False, + }, + { + "label": "RHR (bpm)", + "data": rhr_values, + "borderColor": "#3B82F6", + "backgroundColor": "rgba(59, 130, 246, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y2", + "fill": False, + }, + ] + + return { + "chart_type": "line", + "data": {"labels": labels, "datasets": datasets}, + "metadata": serialize_dates( + { + "confidence": calculate_confidence(len(rows), days, "general"), + "data_points": len(rows), + "avg_hrv": round(avg_hrv, 1), + "avg_rhr": round(avg_rhr, 1), + "hrv_vs_baseline_pct": hrv_baseline, + "rhr_vs_baseline_pct": rhr_baseline, + } + ), + } + + +def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + duration_data = get_sleep_duration_data(profile_id, days) + quality_data = get_sleep_quality_data(profile_id, days) + + if duration_data["confidence"] == "insufficient": + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten vorhanden", + }, + } + + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, total_sleep_min + FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten", + }, + } + + labels = [row["date"].isoformat() for row in rows] + duration_hours = [safe_float(row["total_sleep_min"]) / 60 if row["total_sleep_min"] else None for row in rows] + + quality_scores = [(d / 8 * 100) if d else None for d in duration_hours] + + datasets = [ + { + "label": "Schlafdauer (h)", + "data": duration_hours, + "borderColor": "#3B82F6", + "backgroundColor": "rgba(59, 130, 246, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y1", + "fill": True, + }, + { + "label": "Qualität (%)", + "data": quality_scores, + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y2", + "fill": False, + }, + ] + + return { + "chart_type": "line", + "data": {"labels": labels, "datasets": datasets}, + "metadata": serialize_dates( + { + "confidence": duration_data["confidence"], + "data_points": len(rows), + "avg_duration_hours": round(duration_data["avg_duration_hours"], 1), + "sleep_quality_score": quality_data.get("sleep_quality_score", 0), + } + ), + } + + +def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + current_debt = calculate_sleep_debt_hours(profile_id) + + if current_debt is None: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten für Schulden-Berechnung", + }, + } + + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, total_sleep_min + FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten", + }, + } + + labels = [row["date"].isoformat() for row in rows] + + target_hours = 8.0 + cumulative_debt = 0.0 + debt_values = [] + + for row in rows: + actual_hours = safe_float(row["total_sleep_min"]) / 60 if row["total_sleep_min"] else 0 + daily_deficit = target_hours - actual_hours + cumulative_debt += daily_deficit + debt_values.append(cumulative_debt) + + return { + "chart_type": "line", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Schlafschuld (Stunden)", + "data": debt_values, + "borderColor": "#EF4444", + "backgroundColor": "rgba(239, 68, 68, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "fill": True, + } + ], + }, + "metadata": serialize_dates( + { + "confidence": calculate_confidence(len(rows), days, "general"), + "data_points": len(rows), + "current_debt_hours": round(float(current_debt), 1), + "final_debt_hours": round(float(cumulative_debt), 1), + } + ), + } + + +def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 30: + days = 30 + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT resting_hr, hrv, vo2_max, spo2, respiratory_rate + FROM vitals_baseline + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC + LIMIT 1""", + (profile_id, cutoff), + ) + vitals_row = cur.fetchone() + + cur.execute( + """SELECT systolic, diastolic + FROM blood_pressure_log + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC, time DESC + LIMIT 1""", + (profile_id, cutoff), + ) + bp_row = cur.fetchone() + + if not vitals_row and not bp_row: + return { + "chart_type": "bar", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine aktuellen Vitalwerte", + }, + } + + labels = [] + values = [] + + if vitals_row: + if vitals_row["resting_hr"]: + labels.append("Ruhepuls (bpm)") + values.append(safe_float(vitals_row["resting_hr"])) + if vitals_row["hrv"]: + labels.append("HRV (ms)") + values.append(safe_float(vitals_row["hrv"])) + if vitals_row["vo2_max"]: + labels.append("VO2 Max") + values.append(safe_float(vitals_row["vo2_max"])) + if vitals_row["spo2"]: + labels.append("SpO2 (%)") + values.append(safe_float(vitals_row["spo2"])) + if vitals_row["respiratory_rate"]: + labels.append("Atemfrequenz") + values.append(safe_float(vitals_row["respiratory_rate"])) + + if bp_row: + if bp_row["systolic"]: + labels.append("Blutdruck sys (mmHg)") + values.append(safe_float(bp_row["systolic"])) + if bp_row["diastolic"]: + labels.append("Blutdruck dia (mmHg)") + values.append(safe_float(bp_row["diastolic"])) + + if not labels: + return { + "chart_type": "bar", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Vitalwerte verfügbar", + }, + } + + return { + "chart_type": "bar", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Wert", + "data": values, + "backgroundColor": "#1D9E75", + "borderColor": "#085041", + "borderWidth": 1, + } + ], + }, + "metadata": { + "confidence": "medium", + "data_points": len(values), + "note": "Latest measurements within last " + str(days) + " days", + }, + } diff --git a/backend/data_layer/recovery_interpretation.py b/backend/data_layer/recovery_interpretation.py new file mode 100644 index 0000000..8be9863 --- /dev/null +++ b/backend/data_layer/recovery_interpretation.py @@ -0,0 +1,183 @@ +""" +KPIs und Kurz-Aussagen für Recovery-Dashboard (Layer 2b). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def _verdict(status: str) -> str: + if status == "good": + return "Gut" + if status == "warn": + return "Hinweis" + return "Achtung" + + +def _recovery_score_status(score: Optional[int]) -> str: + if score is None: + return "warn" + if score >= 70: + return "good" + if score >= 45: + return "warn" + return "bad" + + +def _debt_status(hours: Optional[float]) -> str: + if hours is None: + return "warn" + if hours <= 2: + return "good" + if hours <= 8: + return "warn" + return "bad" + + +def build_recovery_dashboard_kpi_tiles( + recovery_score: Optional[int], + sleep_debt_hours: Optional[float], + avg_sleep_hours: Optional[float], + hrv_vs_baseline_pct: Optional[float], + rhr_vs_baseline_pct: Optional[float], +) -> List[Dict[str, Any]]: + tiles: List[Dict[str, Any]] = [] + + rs = _recovery_score_status(recovery_score) + tiles.append( + { + "key": "recovery_score", + "category": "Recovery-Score", + "icon": "💚", + "value": str(recovery_score) if recovery_score is not None else "—", + "sublabel": "Modell aus Schlaf + Vitaldaten", + "status": rs, + "verdict": _verdict(rs), + "hoverTop": "Gesamt-Recovery-Score (0–100)", + "hoverBody": "calculate_recovery_score_v2 — gleiche Quelle wie Platzhalter.", + "keys": ["recovery_score"], + } + ) + + ds = _debt_status(sleep_debt_hours) + tiles.append( + { + "key": "sleep_debt", + "category": "Schlafschuld", + "icon": "⏳", + "value": f"{sleep_debt_hours:.1f} h".replace(".", ",") + if sleep_debt_hours is not None + else "—", + "sublabel": "Kumuliert (Ziel 8 h/Nacht)", + "status": ds, + "verdict": _verdict(ds), + "hoverTop": "Geschätzte Schlafschuld", + "hoverBody": "calculate_sleep_debt_hours", + "keys": ["sleep_debt_hours"], + } + ) + + 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 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"], + } + ) + + return tiles + + +def build_recovery_progress_insights( + recovery_score: Optional[int], + sleep_debt_hours: Optional[float], + hrv_vs_baseline_pct: Optional[float], +) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + + if recovery_score is not None: + tone = "good" if recovery_score >= 65 else "warn" if recovery_score >= 45 else "bad" + out.append( + { + "key": "ins_rec", + "tone": tone, + "title": "Gesamterholung", + "body": f"Der Recovery-Score liegt bei {recovery_score}/100. " + "Er kombiniert Schlaf- und Vital-Signale — ideal für die Einordnung von Trainingstagen.", + } + ) + + if sleep_debt_hours is not None: + tone = "good" if sleep_debt_hours <= 3 else "warn" if sleep_debt_hours <= 10 else "bad" + out.append( + { + "key": "ins_debt", + "tone": tone, + "title": "Schlaf nachholen", + "body": f"Geschätzte Schlafschuld: {sleep_debt_hours:.1f} h. " + "Hohe Schulden erhöhen Verletzungs- und Ermüdungsrisiko — Priorität Schlafhygiene.", + } + ) + + if hrv_vs_baseline_pct is not None: + tone = "good" if hrv_vs_baseline_pct >= 0 else "warn" + out.append( + { + "key": "ins_hrv", + "tone": tone, + "title": "Autonomes System", + "body": f"HRV liegt {hrv_vs_baseline_pct:+.1f} % relativ zur Basis. " + "Positive Werte werden oft mit guter Regeneration assoziiert (individuell interpretieren).", + } + ) + + return out diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py new file mode 100644 index 0000000..2decd00 --- /dev/null +++ b/backend/data_layer/recovery_viz.py @@ -0,0 +1,111 @@ +""" +Layer 2b: Recovery/Erholung — Bundle für Verlauf unter Fitness (Issue 53). +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from db import get_db, get_cursor +from data_layer.recovery_chart_payloads import ( + build_hrv_rhr_baseline_chart_payload, + build_recovery_score_chart_payload, + build_sleep_debt_chart_payload, + build_sleep_duration_quality_chart_payload, + build_vital_signs_matrix_chart_payload, +) +from data_layer.recovery_interpretation import ( + build_recovery_dashboard_kpi_tiles, + build_recovery_progress_insights, +) +from data_layer.recovery_metrics import ( + calculate_hrv_vs_baseline_pct, + calculate_recovery_score_v2, + calculate_rhr_vs_baseline_pct, + calculate_sleep_debt_hours, + get_sleep_duration_data, +) + + +def _has_recovery_sources(profile_id: str) -> bool: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT 1 FROM sleep_log WHERE profile_id=%s LIMIT 1", (profile_id,)) + if cur.fetchone(): + return True + cur.execute("SELECT 1 FROM vitals_baseline WHERE profile_id=%s LIMIT 1", (profile_id,)) + return cur.fetchone() is not None + + +def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: + """ + Ein Request: KPIs, Insights, Charts R1–R5 (Chart.js-kompatibel). + """ + if not _has_recovery_sources(profile_id): + return { + "confidence": "insufficient", + "has_recovery_data": False, + "message": "Noch keine Schlaf- oder Vitaldaten", + "kpi_tiles": [], + "progress_insights": [], + "charts": {}, + "meta": {"layer_1": "recovery_metrics", "layer_2b": "recovery_viz"}, + } + + all_history = days >= 9999 + eff_days = 3650 if all_history else max(7, min(int(days), 3650)) + chart_days = min(90, max(7, min(eff_days, 365))) + vital_days = min(30, max(7, chart_days)) + + recovery_score_val = calculate_recovery_score_v2(profile_id) + sleep_debt = calculate_sleep_debt_hours(profile_id) + dur = get_sleep_duration_data(profile_id, chart_days) + avg_sleep = None + if dur.get("confidence") != "insufficient": + avg_sleep = float(dur.get("avg_duration_hours") or 0) or None + + hrv_dev = calculate_hrv_vs_baseline_pct(profile_id) + rhr_dev = calculate_rhr_vs_baseline_pct(profile_id) + + kpi_tiles = build_recovery_dashboard_kpi_tiles( + recovery_score_val, + float(sleep_debt) if sleep_debt is not None else None, + avg_sleep, + float(hrv_dev) if hrv_dev is not None else None, + float(rhr_dev) if rhr_dev is not None else None, + ) + + insights = build_recovery_progress_insights( + recovery_score_val, + float(sleep_debt) if sleep_debt is not None else None, + float(hrv_dev) if hrv_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), + } + + conf = "medium" + if recovery_score_val is None and sleep_debt is None: + conf = "low" + + return { + "confidence": conf, + "has_recovery_data": True, + "days_requested": days, + "effective_window_days": eff_days, + "chart_days_used": chart_days, + "vital_matrix_days_used": vital_days, + "kpi_tiles": kpi_tiles, + "progress_insights": insights, + "charts": charts, + "meta": { + "layer_1": "recovery_metrics", + "layer_2b": "recovery_viz", + "issue": "53-layer-2b-recovery", + }, + } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 80229dc..508cff0 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -34,6 +34,14 @@ from data_layer.body_metrics import ( from data_layer.body_viz import get_body_history_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle +from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle +from data_layer.recovery_chart_payloads import ( + build_recovery_score_chart_payload, + build_hrv_rhr_baseline_chart_payload, + build_sleep_duration_quality_chart_payload, + build_sleep_debt_chart_payload, + build_vital_signs_matrix_chart_payload, +) from data_layer.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, @@ -310,6 +318,24 @@ def get_fitness_dashboard_viz( return serialize_dates(bundle) +@router.get("/recovery-dashboard-viz") +def get_recovery_dashboard_viz( + days: int = Query( + default=28, + ge=7, + le=9999, + description="Analysefenster in Tagen (9999 = lange Historie)", + ), + session: dict = Depends(require_auth), +) -> Dict: + """ + Layer 2b: Recovery/Erholung — KPIs, Insights, Charts R1–R5 (recovery_metrics). + """ + profile_id = session["profile_id"] + bundle = get_recovery_dashboard_viz_bundle(profile_id, days) + return serialize_dates(bundle) + + @router.get("/circumferences") def get_circumferences_chart( max_age_days: int = Query(default=90, ge=7, le=365), @@ -1368,106 +1394,9 @@ def get_recovery_score_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Recovery score timeline (R1). - - Shows daily recovery scores over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js line chart with recovery scores - """ - profile_id = session['profile_id'] - - # For PoC: Use current recovery score and create synthetic timeline - # TODO: Store historical recovery scores for true timeline - current_score = calculate_recovery_score_v2(profile_id) - - if current_score is None: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Recovery-Daten vorhanden" - } - } - - # Fetch vitals for timeline approximation - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, resting_hr, hrv_ms - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [datetime.now().strftime('%Y-%m-%d')], - "datasets": [ - { - "label": "Recovery Score", - "data": [current_score], - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": { - "confidence": "low", - "data_points": 1, - "current_score": current_score - } - } - - # Simple proxy: Use HRV as recovery indicator (higher HRV = better recovery) - # This is a placeholder until we store actual recovery scores - labels = [row['date'].isoformat() for row in rows] - # Normalize HRV to 0-100 scale (assume typical range 20-100ms) - values = [min(100, max(0, safe_float(row['hrv_ms']) if row['hrv_ms'] else 50)) for row in rows] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Recovery Score (proxy)", - "data": values, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "current_score": current_score, - "note": "Score based on HRV proxy; true recovery score calculation in development" - }) - } + """Recovery score timeline (R1). Delegiert an recovery_chart_payloads.""" + profile_id = session["profile_id"] + return build_recovery_score_chart_payload(profile_id, days) @router.get("/hrv-rhr-baseline") @@ -1475,101 +1404,9 @@ def get_hrv_rhr_baseline_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - HRV/RHR vs baseline (R2). - - Shows HRV and RHR trends vs. baseline values. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js multi-line chart with HRV and RHR - """ - profile_id = session['profile_id'] - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, resting_hr, hrv_ms - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Vitalwerte vorhanden" - } - } - - labels = [row['date'].isoformat() for row in rows] - hrv_values = [safe_float(row['hrv_ms']) if row['hrv_ms'] else None for row in rows] - rhr_values = [safe_float(row['resting_hr']) if row['resting_hr'] else None for row in rows] - - # Calculate baselines (28d median) - hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id) # This returns % deviation - rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id) # This returns % deviation - - # For chart, we need actual baseline values (approximation) - hrv_filtered = [v for v in hrv_values if v is not None] - rhr_filtered = [v for v in rhr_values if v is not None] - - avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50 - avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60 - - datasets = [ - { - "label": "HRV (ms)", - "data": hrv_values, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y1", - "fill": False - }, - { - "label": "RHR (bpm)", - "data": rhr_values, - "borderColor": "#3B82F6", - "backgroundColor": "rgba(59, 130, 246, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y2", - "fill": False - } - ] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": datasets - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "avg_hrv": round(avg_hrv, 1), - "avg_rhr": round(avg_rhr, 1), - "hrv_vs_baseline_pct": hrv_baseline, - "rhr_vs_baseline_pct": rhr_baseline - }) - } + """HRV/RHR vs baseline (R2).""" + profile_id = session["profile_id"] + return build_hrv_rhr_baseline_chart_payload(profile_id, days) @router.get("/sleep-duration-quality") @@ -1577,107 +1414,9 @@ def get_sleep_duration_quality_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Sleep duration + quality (R3). - - Shows sleep duration and quality score over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js multi-line chart with sleep metrics - """ - profile_id = session['profile_id'] - - duration_data = get_sleep_duration_data(profile_id, days) - quality_data = get_sleep_quality_data(profile_id, days) - - if duration_data['confidence'] == 'insufficient': - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten vorhanden" - } - } - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, total_sleep_min - FROM sleep_log - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten" - } - } - - labels = [row['date'].isoformat() for row in rows] - duration_hours = [safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else None for row in rows] - - # Quality score (simple proxy: % of 8 hours) - quality_scores = [(d / 8 * 100) if d else None for d in duration_hours] - - datasets = [ - { - "label": "Schlafdauer (h)", - "data": duration_hours, - "borderColor": "#3B82F6", - "backgroundColor": "rgba(59, 130, 246, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y1", - "fill": True - }, - { - "label": "Qualität (%)", - "data": quality_scores, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y2", - "fill": False - } - ] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": datasets - }, - "metadata": serialize_dates({ - "confidence": duration_data['confidence'], - "data_points": len(rows), - "avg_duration_hours": round(duration_data['avg_duration_hours'], 1), - "sleep_quality_score": quality_data.get('sleep_quality_score', 0) - }) - } + """Sleep duration + quality (R3).""" + profile_id = session["profile_id"] + return build_sleep_duration_quality_chart_payload(profile_id, days) @router.get("/sleep-debt") @@ -1685,100 +1424,9 @@ def get_sleep_debt_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Sleep debt accumulation (R4). - - Shows cumulative sleep debt over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js line chart with sleep debt - """ - profile_id = session['profile_id'] - - current_debt = calculate_sleep_debt_hours(profile_id) - - if current_debt is None: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten für Schulden-Berechnung" - } - } - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, total_sleep_min - FROM sleep_log - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten" - } - } - - labels = [row['date'].isoformat() for row in rows] - - # Calculate cumulative debt (target 8h/night) - target_hours = 8.0 - cumulative_debt = 0 - debt_values = [] - - for row in rows: - actual_hours = safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else 0 - daily_deficit = target_hours - actual_hours - cumulative_debt += daily_deficit - debt_values.append(cumulative_debt) - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Schlafschuld (Stunden)", - "data": debt_values, - "borderColor": "#EF4444", - "backgroundColor": "rgba(239, 68, 68, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "current_debt_hours": round(current_debt, 1), - "final_debt_hours": round(cumulative_debt, 1) - }) - } + """Sleep debt (R4).""" + profile_id = session["profile_id"] + return build_sleep_debt_chart_payload(profile_id, days) @router.get("/vital-signs-matrix") @@ -1786,123 +1434,9 @@ def get_vital_signs_matrix_chart( days: int = Query(default=7, ge=7, le=30), session: dict = Depends(require_auth) ) -> Dict: - """ - Vital signs matrix (R5). - - Shows latest vital signs as horizontal bar chart. - - Args: - days: Max age of measurements (7-30 days, default 7) - session: Auth session (injected) - - Returns: - Chart.js horizontal bar chart with vital signs - """ - profile_id = session['profile_id'] - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - # Get latest vitals - cur.execute( - """SELECT resting_hr, hrv_ms, vo2_max, spo2, respiratory_rate - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date DESC - LIMIT 1""", - (profile_id, cutoff) - ) - vitals_row = cur.fetchone() - - # Get latest blood pressure - cur.execute( - """SELECT systolic, diastolic - FROM blood_pressure_log - WHERE profile_id=%s AND date >= %s - ORDER BY date DESC, time DESC - LIMIT 1""", - (profile_id, cutoff) - ) - bp_row = cur.fetchone() - - if not vitals_row and not bp_row: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine aktuellen Vitalwerte" - } - } - - labels = [] - values = [] - - if vitals_row: - if vitals_row['resting_hr']: - labels.append("Ruhepuls (bpm)") - values.append(safe_float(vitals_row['resting_hr'])) - if vitals_row['hrv_ms']: - labels.append("HRV (ms)") - values.append(safe_float(vitals_row['hrv_ms'])) - if vitals_row['vo2_max']: - labels.append("VO2 Max") - values.append(safe_float(vitals_row['vo2_max'])) - if vitals_row['spo2']: - labels.append("SpO2 (%)") - values.append(safe_float(vitals_row['spo2'])) - if vitals_row['respiratory_rate']: - labels.append("Atemfrequenz") - values.append(safe_float(vitals_row['respiratory_rate'])) - - if bp_row: - if bp_row['systolic']: - labels.append("Blutdruck sys (mmHg)") - values.append(safe_float(bp_row['systolic'])) - if bp_row['diastolic']: - labels.append("Blutdruck dia (mmHg)") - values.append(safe_float(bp_row['diastolic'])) - - if not labels: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Vitalwerte verfügbar" - } - } - - return { - "chart_type": "bar", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Wert", - "data": values, - "backgroundColor": "#1D9E75", - "borderColor": "#085041", - "borderWidth": 1 - } - ] - }, - "metadata": { - "confidence": "medium", - "data_points": len(values), - "note": "Latest measurements within last " + str(days) + " days" - } - } + """Vital signs matrix (R5).""" + profile_id = session["profile_id"] + return build_vital_signs_matrix_chart_payload(profile_id, days) # ── Correlation Charts ────────────────────────────────────────────────────── diff --git a/frontend/src/components/RecoveryCharts.jsx b/frontend/src/components/RecoveryCharts.jsx index a07cdda..6cad7bd 100644 --- a/frontend/src/components/RecoveryCharts.jsx +++ b/frontend/src/components/RecoveryCharts.jsx @@ -1,320 +1,8 @@ -import { useState, useEffect } from 'react' -import { - LineChart, Line, BarChart, Bar, - XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid -} from 'recharts' -import { api } from '../utils/api' -import dayjs from 'dayjs' - -const fmtDate = d => dayjs(d).format('DD.MM') - -function ChartCard({ title, loading, error, children }) { - return ( -
-
- {title} -
- {loading && ( -
-
-
- )} - {error && ( -
- {error} -
- )} - {!loading && !error && children} -
- ) -} +import RecoveryDashboardOverview from './RecoveryDashboardOverview' /** - * Recovery Charts Component (R1-R5) - * - * Displays 5 recovery chart endpoints: - * - Recovery Score Timeline (R1) - * - HRV/RHR vs Baseline (R2) - * - Sleep Duration + Quality (R3) - * - Sleep Debt (R4) - * - Vital Signs Matrix (R5) + * @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days → period). */ export default function RecoveryCharts({ days = 28 }) { - const [recoveryData, setRecoveryData] = useState(null) - const [hrvRhrData, setHrvRhrData] = useState(null) - const [sleepData, setSleepData] = useState(null) - const [debtData, setDebtData] = useState(null) - const [vitalsData, setVitalsData] = useState(null) - - const [loading, setLoading] = useState({}) - const [errors, setErrors] = useState({}) - - useEffect(() => { - loadCharts() - }, [days]) - - const loadCharts = async () => { - // Load all 5 charts in parallel - await Promise.all([ - loadRecoveryScore(), - loadHrvRhr(), - loadSleepQuality(), - loadSleepDebt(), - loadVitalSigns() - ]) - } - - const loadRecoveryScore = async () => { - setLoading(l => ({...l, recovery: true})) - setErrors(e => ({...e, recovery: null})) - try { - const data = await api.getRecoveryScoreChart(days) - setRecoveryData(data) - } catch (err) { - setErrors(e => ({...e, recovery: err.message})) - } finally { - setLoading(l => ({...l, recovery: false})) - } - } - - const loadHrvRhr = async () => { - setLoading(l => ({...l, hrvRhr: true})) - setErrors(e => ({...e, hrvRhr: null})) - try { - const data = await api.getHrvRhrBaselineChart(days) - setHrvRhrData(data) - } catch (err) { - setErrors(e => ({...e, hrvRhr: err.message})) - } finally { - setLoading(l => ({...l, hrvRhr: false})) - } - } - - const loadSleepQuality = async () => { - setLoading(l => ({...l, sleep: true})) - setErrors(e => ({...e, sleep: null})) - try { - const data = await api.getSleepDurationQualityChart(days) - setSleepData(data) - } catch (err) { - setErrors(e => ({...e, sleep: err.message})) - } finally { - setLoading(l => ({...l, sleep: false})) - } - } - - const loadSleepDebt = async () => { - setLoading(l => ({...l, debt: true})) - setErrors(e => ({...e, debt: null})) - try { - const data = await api.getSleepDebtChart(days) - setDebtData(data) - } catch (err) { - setErrors(e => ({...e, debt: err.message})) - } finally { - setLoading(l => ({...l, debt: false})) - } - } - - const loadVitalSigns = async () => { - setLoading(l => ({...l, vitals: true})) - setErrors(e => ({...e, vitals: null})) - try { - const data = await api.getVitalSignsMatrixChart(7) // Last 7 days - setVitalsData(data) - } catch (err) { - setErrors(e => ({...e, vitals: err.message})) - } finally { - setLoading(l => ({...l, vitals: false})) - } - } - - // R1: Recovery Score Timeline - const renderRecoveryScore = () => { - if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { - return
- Keine Recovery-Daten vorhanden -
- } - - const chartData = recoveryData.data.labels.map((label, i) => ({ - date: fmtDate(label), - score: recoveryData.data.datasets[0]?.data[i] - })) - - return ( - <> - - - - - - - - - -
- Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge -
- - ) - } - - // R2: HRV/RHR vs Baseline - const renderHrvRhr = () => { - if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') { - return
- Keine Vitalwerte vorhanden -
- } - - const chartData = hrvRhrData.data.labels.map((label, i) => ({ - date: fmtDate(label), - hrv: hrvRhrData.data.datasets[0]?.data[i], - rhr: hrvRhrData.data.datasets[1]?.data[i] - })) - - return ( - <> - - - - - - - - - - - -
- HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm -
- - ) - } - - // R3: Sleep Duration + Quality - const renderSleepQuality = () => { - if (!sleepData || sleepData.metadata?.confidence === 'insufficient') { - return
- Keine Schlafdaten vorhanden -
- } - - const chartData = sleepData.data.labels.map((label, i) => ({ - date: fmtDate(label), - duration: sleepData.data.datasets[0]?.data[i], - quality: sleepData.data.datasets[1]?.data[i] - })) - - return ( - <> - - - - - - - - - - - -
- Ø {sleepData.metadata.avg_duration_hours}h Schlaf -
- - ) - } - - // R4: Sleep Debt - const renderSleepDebt = () => { - if (!debtData || debtData.metadata?.confidence === 'insufficient') { - return
- Keine Schlafdaten für Schulden-Berechnung -
- } - - const chartData = debtData.data.labels.map((label, i) => ({ - date: fmtDate(label), - debt: debtData.data.datasets[0]?.data[i] - })) - - return ( - <> - - - - - - - - - -
- Aktuelle Schuld: {debtData.metadata.current_debt_hours.toFixed(1)}h -
- - ) - } - - // R5: Vital Signs Matrix (Bar) - const renderVitalSigns = () => { - if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { - return
- Keine aktuellen Vitalwerte -
- } - - const chartData = vitalsData.data.labels.map((label, i) => ({ - name: label, - value: vitalsData.data.datasets[0]?.data[i] - })) - - return ( - <> - - - - - - - - - -
- Letzte {vitalsData.metadata.data_points} Messwerte (7 Tage) -
- - ) - } - - return ( -
- - {renderRecoveryScore()} - - - - {renderHrvRhr()} - - - - {renderSleepQuality()} - - - - {renderSleepDebt()} - - - - {renderVitalSigns()} - -
- ) + return } diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx new file mode 100644 index 0000000..31f0802 --- /dev/null +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -0,0 +1,402 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from 'recharts' +import { api } from '../utils/api' +import KpiTilesOverview from './KpiTilesOverview' +import { getStatusColor } from '../utils/interpret' +import dayjs from 'dayjs' + +const fmtDate = (d) => dayjs(d).format('DD.MM.') + +function ChartCard({ title, loading, error, children }) { + return ( +
+
{title}
+ {loading && ( +
+
+
+ )} + {error && ( +
{error}
+ )} + {!loading && !error && children} +
+ ) +} + +/** + * Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics). + */ +export default function RecoveryDashboardOverview({ + period: periodProp, + onPeriodChange, + hidePeriodSelector = false, +}) { + const nav = useNavigate() + const [internalPeriod, setInternalPeriod] = useState(28) + const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' + const period = controlled ? periodProp : internalPeriod + const setPeriod = controlled ? onPeriodChange : setInternalPeriod + + const [viz, setViz] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setErr(null) + api + .getRecoveryDashboardViz(period) + .then((v) => { + if (!cancelled) setViz(v) + }) + .catch((e) => { + if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [period]) + + if (loading) { + return ( +
+
Erholung & Vitalwerte
+
+
+ ) + } + + if (err) { + return ( +
+
Erholung & Vitalwerte
+
{err}
+
+ ) + } + + if (!viz?.has_recovery_data) { + return ( +
+
Erholung & Vitalwerte
+

+ {viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst + oder importierst, erscheinen Auswertungen hier. +

+ +
+ ) + } + + const recoveryData = viz.charts?.recovery_score + const hrvRhrData = viz.charts?.hrv_rhr + const sleepData = viz.charts?.sleep_duration_quality + const debtData = viz.charts?.sleep_debt + const vitalsData = viz.charts?.vital_signs_matrix + + const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ + ...t, + sublabel: + typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, + })) + const insights = viz.progress_insights || [] + const eff = viz.effective_window_days + const cDays = viz.chart_days_used + const vDays = viz.vital_matrix_days_used + + const showPeriodDropdown = !hidePeriodSelector && !controlled + + const renderRecoveryScore = () => { + if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Recovery-Daten im Fenster +
+ ) + } + const chartData = recoveryData.data.labels.map((label, i) => ({ + date: fmtDate(label), + score: recoveryData.data.datasets[0]?.data[i], + })) + return ( + <> + + + + + + + + + +
+ Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge +
+ + ) + } + + const renderHrvRhr = () => { + if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Vitalwerte im Fenster +
+ ) + } + const chartData = hrvRhrData.data.labels.map((label, i) => ({ + date: fmtDate(label), + hrv: hrvRhrData.data.datasets[0]?.data[i], + rhr: hrvRhrData.data.datasets[1]?.data[i], + })) + return ( + <> + + + + + + + + + + + +
+ HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm +
+ + ) + } + + const renderSleepQuality = () => { + if (!sleepData || sleepData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Schlafdaten im Fenster +
+ ) + } + const chartData = sleepData.data.labels.map((label, i) => ({ + date: fmtDate(label), + duration: sleepData.data.datasets[0]?.data[i], + quality: sleepData.data.datasets[1]?.data[i], + })) + return ( + <> + + + + + + + + + + + +
+ Ø {sleepData.metadata.avg_duration_hours}h Schlaf +
+ + ) + } + + const renderSleepDebt = () => { + if (!debtData || debtData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Schlafdaten für Schulden-Berechnung +
+ ) + } + const chartData = debtData.data.labels.map((label, i) => ({ + date: fmtDate(label), + debt: debtData.data.datasets[0]?.data[i], + })) + const curDebt = debtData.metadata?.current_debt_hours + return ( + <> + + + + + + + + + +
+ Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h +
+ + ) + } + + const renderVitalSigns = () => { + if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine aktuellen Vitalwerte +
+ ) + } + const chartData = vitalsData.data.labels.map((label, i) => ({ + name: label, + value: vitalsData.data.datasets[0]?.data[i], + })) + return ( + <> + + + + + + + + + +
+ Letzte {vitalsData.metadata.data_points} Messwerte ({vDays} Tage) +
+ + ) + } + + return ( +
+
+ Erholung & Vitalwerte + {showPeriodDropdown ? ( + + ) : null} +
+ +

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

+ + + + {insights.length > 0 ? ( +
+
Einschätzungen
+
+ {insights.map((ins) => ( +
+
{ins.title}
+
{ins.body}
+
+ ))} +
+
+ ) : null} + +
Diagramme
+ + {renderRecoveryScore()} + {renderHrvRhr()} + {renderSleepQuality()} + {renderSleepDebt()} + {renderVitalSigns()} +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx index 4047427..218cb81 100644 --- a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx +++ b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx @@ -1,9 +1,9 @@ import { useNavigate } from 'react-router-dom' -import RecoveryCharts from '../RecoveryCharts' +import RecoveryDashboardOverview from '../RecoveryDashboardOverview' import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' /** - * Erholung R1–R5 (wie Verlauf Erholung). + * Erholung Layer 2b (ein Bundle-Request). Link zum Verlauf unter Fitness. * @param {{ refreshTick?: number, chartDays?: number }} props */ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }) { @@ -11,22 +11,22 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays } const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 28 return ( -
+
-
Erholung — Charts
+
Erholung — Übersicht
Schlaf, Recovery, Vitalwerte · {days} Tage
- +
) } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 2a78d14..36c73bf 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -15,7 +15,7 @@ import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '.. import Markdown from '../utils/Markdown' import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' -import RecoveryCharts from '../components/RecoveryCharts' +import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' import KpiTilesOverview from '../components/KpiTilesOverview' import dayjs from 'dayjs' import 'dayjs/locale/de' @@ -1108,11 +1108,15 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA

- Auswertung ausschließlich aus dem Fitness-Bundle (Data-Layer / Issue 53). Zeitraum-Buttons steuern dasselbe - Fenster wie die API. + Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.

+
+ Erholung (Schlaf, HRV, Vitalwerte) +
+ + {hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
)} - {hasList ? ( - - ) : null} +
) } @@ -1432,32 +1439,10 @@ function PhotoGrid() { } // ── Main ────────────────────────────────────────────────────────────────────── -// ── Recovery Section ────────────────────────────────────────────────────────── -function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { - const [period, setPeriod] = useState(28) - - return ( -
- - - -
- Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick. -
- - {/* Recovery Charts (Phase 0c) */} - - - -
- ) -} - const TABS = [ { id:'body', label:'⚖️ Körper' }, { id:'nutrition', label:'🍽️ Ernährung' }, { id:'activity', label:'🏋️ Fitness' }, - { id:'recovery', label:'😴 Erholung' }, { id:'correlation', label:'🔗 Korrelation' }, { id:'photos', label:'📷 Fotos' }, ] @@ -1497,6 +1482,10 @@ export default function History() { useEffect(() => { const t = location.state?.tab + if (t === 'recovery') { + setTab('activity') + return + } if (t && TABS.some(x => x.id === t)) setTab(t) }, [location.state?.tab]) @@ -1544,7 +1533,6 @@ export default function History() { {tab==='body' && } {tab==='nutrition' && } {tab==='activity' && } - {tab==='recovery' && } {tab==='correlation' && } {tab==='photos' && }
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 9185df7..18ba7e9 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -641,6 +641,8 @@ export const api = { getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`), /** Layer 2b: Fitness-Übersicht — KPI + Volumen/Typ-Charts (activity_metrics) */ getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`), + /** Layer 2b: Erholung — KPI, Insights, Charts R1–R5 (recovery_metrics) */ + getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`), getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),