- Introduced new functions to handle vital signs data retrieval and processing, including fallback mechanisms for missing values. - Updated SQL queries in `build_vital_signs_matrix_chart_payload` to improve date filtering and data accuracy. - Enhanced the frontend `RecoveryDashboardOverview` component to display vital signs with contextual coloring based on health tones. - Adjusted the data structure for chart rendering, ensuring a more informative and visually appealing representation of vital metrics.
513 lines
16 KiB
Python
513 lines
16 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
|
||
|
||
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),
|
||
}
|
||
),
|
||
}
|
||
|
||
|
||
def _vitals_row_has_any_value(row: Any) -> bool:
|
||
if not row:
|
||
return False
|
||
for k in ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate"):
|
||
if row.get(k) is not None:
|
||
return True
|
||
return False
|
||
|
||
|
||
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) -> Dict[str, Any]:
|
||
"""Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1)."""
|
||
if days < 7:
|
||
days = 7
|
||
if days > 365:
|
||
days = 365
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
vitals_row = None
|
||
bp_row = None
|
||
vitals_measured_at = None
|
||
bp_measured_at = 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 1""",
|
||
(profile_id, cutoff),
|
||
)
|
||
vitals_row = cur.fetchone()
|
||
if vitals_row and vitals_row.get("date") is not None:
|
||
d = vitals_row["date"]
|
||
vitals_measured_at = d.isoformat() if hasattr(d, "isoformat") else str(d)
|
||
|
||
if not _vitals_row_has_any_value(vitals_row):
|
||
cur.execute(
|
||
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
||
FROM vitals_baseline
|
||
WHERE profile_id=%s
|
||
ORDER BY date DESC
|
||
LIMIT 1""",
|
||
(profile_id,),
|
||
)
|
||
vitals_row = cur.fetchone()
|
||
if vitals_row and vitals_row.get("date") is not None:
|
||
d = vitals_row["date"]
|
||
vitals_measured_at = d.isoformat() if hasattr(d, "isoformat") else str(d)
|
||
|
||
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"]
|
||
|
||
# Dict-like rows for assessment (exclude date/measured_at from value checks)
|
||
vitals_for_items = dict(vitals_row) if vitals_row else None
|
||
if vitals_for_items and "date" in vitals_for_items:
|
||
vitals_for_items = {k: v for k, v in vitals_for_items.items() if k != "date"}
|
||
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)
|
||
|
||
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.",
|
||
}
|
||
),
|
||
}
|