""" 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, Optional, Set 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 from data_layer.vital_signs_assessment import build_vital_items_from_rows 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, duration_minutes 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["duration_minutes"]) / 60 if row["duration_minutes"] 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("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, duration_minutes 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["duration_minutes"]) / 60 if row["duration_minutes"] 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), } ), } VITAL_BASELINE_KEYS = ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate") def _vitals_row_has_any_value(row: Any) -> bool: if not row: return False for k in VITAL_BASELINE_KEYS: if row.get(k) is not None: return True return False def _merge_vitals_baseline_rows(rows: Any) -> tuple[Optional[Dict[str, Any]], Optional[Any]]: """ Pro Kennzahl den jeweils neuesten nicht-leeren Wert (Zeilen sortiert: date DESC). So können KPIs (Aggregation über Zeilen) Daten haben, obwohl die jüngste Zeile leer ist. """ if not rows: return None, None merged: Dict[str, Any] = {k: None for k in VITAL_BASELINE_KEYS} for row in rows: for k in VITAL_BASELINE_KEYS: if merged[k] is None and row.get(k) is not None: merged[k] = row[k] if all(merged[k] is not None for k in VITAL_BASELINE_KEYS): break if not _vitals_row_has_any_value(merged): return None, None newest_date = rows[0].get("date") if rows else None return merged, newest_date def _bp_row_complete(row: Any) -> bool: return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None) 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, 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: days = 365 cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") bp_row = None vitals_measured_at = None bp_measured_at = None vitals_for_items: Optional[Dict[str, Any]] = None 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 DESC LIMIT 200""", (profile_id, cutoff), ) vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall()) if vitals_merged is None: cur.execute( """SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate FROM vitals_baseline WHERE profile_id=%s ORDER BY date DESC LIMIT 400""", (profile_id,), ) vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall()) if vitals_merged is not None: vitals_for_items = dict(vitals_merged) if vitals_date is not None: vitals_measured_at = vitals_date.isoformat() if hasattr(vitals_date, "isoformat") else str(vitals_date) cur.execute( """SELECT measured_at, systolic, diastolic FROM blood_pressure_log WHERE profile_id=%s AND measured_at::date >= %s::date ORDER BY measured_at DESC LIMIT 1""", (profile_id, cutoff), ) bp_row = cur.fetchone() if bp_row and bp_row.get("measured_at") is not None: bp_measured_at = bp_row["measured_at"] if not _bp_row_complete(bp_row): cur.execute( """SELECT measured_at, systolic, diastolic FROM blood_pressure_log WHERE profile_id=%s ORDER BY measured_at DESC LIMIT 1""", (profile_id,), ) bp_row = cur.fetchone() if bp_row and bp_row.get("measured_at") is not None: bp_measured_at = bp_row["measured_at"] bp_for_items = None 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, 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 { "chart_type": "bar", "data": {"labels": [], "datasets": []}, "metadata": { "confidence": "insufficient", "data_points": 0, "message": "Keine Vitalwerte mit Zahlenwerten — Baseline-Vitals und/oder Blutdruck erfassen.", "vital_items": [], "vitals_measured_at": vitals_measured_at, "blood_pressure_measured_at": bp_measured_at.isoformat() if bp_measured_at and hasattr(bp_measured_at, "isoformat") else None, }, } for it in items: it["bar_value"] = round(_tone_to_bar_value(it["tone"]), 1) labels_short = [it["label_de"] for it in items] bar_values = [it["bar_value"] for it in items] colors = [] for it in items: t = it["tone"] if t == "good": colors.append("#1D9E75") elif t == "warn": colors.append("#EF9F27") elif t == "bad": colors.append("#D85A30") else: colors.append("#6B7280") return { "chart_type": "bar", "data": { "labels": labels_short, "datasets": [ { "label": "Einschätzung (relativ)", "data": bar_values, "backgroundColor": colors, "borderColor": colors, "borderWidth": 1, } ], }, "metadata": serialize_dates( { "confidence": "medium", "data_points": len(items), "note": "Orientierende Zonen, keine Diagnose. Balken = relative Einordnung (nicht körperliche Einheit).", "vital_items": items, "bar_is_relative_score": True, "vitals_measured_at": vitals_measured_at, "blood_pressure_measured_at": bp_measured_at.isoformat() if bp_measured_at and hasattr(bp_measured_at, "isoformat") else (str(bp_measured_at) if bp_measured_at else None), "disclaimer_de": "Hinweis: Nur Orientierung; bei Beschwerden oder auffälligen Werten ärztlich abklären.", } ), }