""" 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", }, }