- Updated the `build_vital_signs_matrix_chart_payload` function to accept optional keys for omitting specific snapshot data, improving flexibility in data presentation. - Enhanced the `build_recovery_dashboard_kpi_tiles` function to conditionally merge heart and autonomic tiles based on new parameters, refining the dashboard's insights. - Integrated new analytics features in the `RecoveryDashboardOverview` component, including consolidated paragraphs for better narrative context and visual representation of trends. - Improved the handling of vital signs data in the frontend, ensuring clearer messaging and enhanced user experience when displaying vital metrics.
540 lines
17 KiB
Python
540 lines
17 KiB
Python
"""
|
||
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.",
|
||
}
|
||
),
|
||
}
|