feat: enhance recovery dashboard with vital signs analytics and visualization improvements
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 18s

- 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.
This commit is contained in:
Lars 2026-04-20 10:29:43 +02:00
parent e7bcdc3228
commit 8cb5ad992f
7 changed files with 299 additions and 166 deletions

View File

@ -7,7 +7,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, Optional from typing import Any, Dict, Optional, Set
from db import get_db, get_cursor from db import get_db, get_cursor
from data_layer.recovery_metrics import ( from data_layer.recovery_metrics import (
@ -396,8 +396,15 @@ 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) 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]: def build_vital_signs_matrix_chart_payload(
"""Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1).""" 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: if days < 7:
days = 7 days = 7
if days > 365: if days > 365:
@ -464,7 +471,11 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s
if bp_row: if bp_row:
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")} 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) 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: if not items:
return { return {

View File

@ -41,6 +41,7 @@ def build_recovery_dashboard_kpi_tiles(
avg_sleep_hours: Optional[float], avg_sleep_hours: Optional[float],
hrv_vs_baseline_pct: Optional[float], hrv_vs_baseline_pct: Optional[float],
rhr_vs_baseline_pct: Optional[float], rhr_vs_baseline_pct: Optional[float],
merge_heart_autonomic_tiles: bool = True,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
tiles: List[Dict[str, Any]] = [] tiles: List[Dict[str, Any]] = []
@ -93,46 +94,76 @@ def build_recovery_dashboard_kpi_tiles(
} }
) )
h_s = ( if merge_heart_autonomic_tiles and (
"good" hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None
if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0 ):
else "warn" h_s = (
if hrv_vs_baseline_pct is not None "good"
else "warn" if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0
) 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 if hrv_vs_baseline_pct is not None
else "", else "warn"
"sublabel": "Letzte 3 Tage vs. ältere Basis", )
"status": h_s, parts: List[str] = []
"verdict": _verdict(h_s), if hrv_vs_baseline_pct is not None:
"hoverTop": "Abweichung HRV vom Referenzmittel", parts.append(f"HRV {hrv_vs_baseline_pct:+.1f} %".replace(".", ","))
"hoverBody": "calculate_hrv_vs_baseline_pct", if rhr_vs_baseline_pct is not None:
"keys": ["hrv_vs_baseline"], parts.append(f"RHR {rhr_vs_baseline_pct:+.1f} %".replace(".", ","))
} tiles.append(
) {
"key": "herz_autonom",
"category": "Herz & autonomes System",
"icon": "❤️‍🩹",
"value": " · ".join(parts) if parts else "",
"sublabel": "HRV/Ruhepuls vs. Referenz (3-Tage-Mittel vs. ältere Basis)",
"status": h_s,
"verdict": _verdict(h_s),
"hoverTop": "HRV und Ruhepuls relativ zur persönlichen Basis",
"hoverBody": "calculate_hrv_vs_baseline_pct · calculate_rhr_vs_baseline_pct",
"keys": ["hrv_vs_baseline", "rhr_vs_baseline"],
}
)
else:
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( tiles.append(
{ {
"key": "rhr_baseline", "key": "rhr_baseline",
"category": "Ruhepuls vs. Basis", "category": "Ruhepuls vs. Basis",
"icon": "❤️", "icon": "❤️",
"value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",") "value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",")
if rhr_vs_baseline_pct is not None if rhr_vs_baseline_pct is not None
else "", else "",
"sublabel": "Niedriger oft günstiger", "sublabel": "Niedriger oft günstiger",
"status": "good", "status": "good",
"verdict": "Gut", "verdict": "Gut",
"hoverTop": "Abweichung Ruhepuls", "hoverTop": "Abweichung Ruhepuls",
"hoverBody": "calculate_rhr_vs_baseline_pct", "hoverBody": "calculate_rhr_vs_baseline_pct",
"keys": ["rhr_vs_baseline"], "keys": ["rhr_vs_baseline"],
} }
) )
return tiles return tiles
@ -141,7 +172,9 @@ def build_recovery_progress_insights(
recovery_score: Optional[int], recovery_score: Optional[int],
sleep_debt_hours: Optional[float], sleep_debt_hours: Optional[float],
hrv_vs_baseline_pct: Optional[float], hrv_vs_baseline_pct: Optional[float],
include_autonomic_hrv_narrative: bool = False,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""HRV-Basistext optional: steckt gebündelt im Vital-Verlauf (consolidated_paragraphs)."""
out: List[Dict[str, Any]] = [] out: List[Dict[str, Any]] = []
if recovery_score is not None: if recovery_score is not None:
@ -168,7 +201,7 @@ def build_recovery_progress_insights(
} }
) )
if hrv_vs_baseline_pct is not None: if include_autonomic_hrv_narrative and hrv_vs_baseline_pct is not None:
tone = "good" if hrv_vs_baseline_pct >= 0 else "warn" tone = "good" if hrv_vs_baseline_pct >= 0 else "warn"
out.append( out.append(
{ {

View File

@ -83,13 +83,22 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
float(hrv_dev) if hrv_dev is not None else None, float(hrv_dev) if hrv_dev is not None else None,
) )
hrv_f = float(hrv_dev) if hrv_dev is not None else None
rhr_f = float(rhr_dev) if rhr_dev is not None else None
charts = { charts = {
"recovery_score": build_recovery_score_chart_payload(profile_id, chart_days), "recovery_score": build_recovery_score_chart_payload(profile_id, chart_days),
"hrv_rhr": build_hrv_rhr_baseline_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_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days),
"sleep_debt": build_sleep_debt_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), "vital_signs_matrix": build_vital_signs_matrix_chart_payload(
"vitals_history": build_vitals_history_and_analytics(profile_id, vital_days), profile_id,
vital_days,
omit_snapshot_keys={"resting_hr", "hrv"},
),
"vitals_history": build_vitals_history_and_analytics(
profile_id, vital_days, hrv_vs_baseline_pct=hrv_f, rhr_vs_baseline_pct=rhr_f
),
} }
conf = "medium" conf = "medium"

View File

@ -5,7 +5,7 @@ Keine Diagnose — typische Referenzbereiche für UI/Coaching.
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Set
from data_layer.utils import safe_float from data_layer.utils import safe_float
@ -104,20 +104,23 @@ def assess_vo2_max(value: float) -> tuple:
def build_vital_items_from_rows( def build_vital_items_from_rows(
vitals_row: Optional[Dict[str, Any]], vitals_row: Optional[Dict[str, Any]],
bp_row: Optional[Dict[str, Any]], bp_row: Optional[Dict[str, Any]],
omit_keys: Optional[Set[str]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""omit_keys: z. B. {'resting_hr','hrv'} wenn Einordnung zentral im Herz-/Autonomie-Block steht."""
skip = omit_keys or set()
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
order = 0 order = 0
if vitals_row: if vitals_row:
rhr = vitals_row.get("resting_hr") rhr = vitals_row.get("resting_hr")
if rhr is not None: if rhr is not None and "resting_hr" not in skip:
v = safe_float(rhr) v = safe_float(rhr)
t, z, h = assess_resting_hr(v) t, z, h = assess_resting_hr(v)
items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order)) items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order))
order += 1 order += 1
hrv = vitals_row.get("hrv") hrv = vitals_row.get("hrv")
if hrv is not None: if hrv is not None and "hrv" not in skip:
v = safe_float(hrv) v = safe_float(hrv)
t, z, h = assess_hrv_ms(v) t, z, h = assess_hrv_ms(v)
items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order)) items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order))

View File

@ -8,7 +8,7 @@ from __future__ import annotations
import statistics import statistics
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence, Tuple from typing import Any, Dict, List, Optional, Sequence
from db import get_db, get_cursor from db import get_db, get_cursor
from data_layer.utils import safe_float, serialize_dates from data_layer.utils import safe_float, serialize_dates
@ -74,6 +74,94 @@ def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, fl
return {r["d"]: float(r["minutes"]) for r in rows} return {r["d"]: float(r["minutes"]) for r in rows}
def _trailing_window_means(vals: List[float], window: int = 7) -> List[float]:
"""Gleitender Mittelwert über die letzten bis zu `window` aufeinanderfolgenden Messungen (nicht Kalendertage)."""
out: List[float] = []
for i in range(len(vals)):
chunk = vals[max(0, i - window + 1) : i + 1]
out.append(round(statistics.mean(chunk), 2))
return out
def _build_consolidated_paragraphs(
series: Dict[str, Any],
hrv_vs_baseline_pct: Optional[float],
rhr_vs_baseline_pct: Optional[float],
r_pearson: Optional[float],
pairs_n: int,
) -> List[str]:
"""Eine zusammenhängende Einordnung statt vieler einzelner Karten zu Puls/HRV/Basis."""
paras: List[str] = []
basis_bits: List[str] = []
if hrv_vs_baseline_pct is not None:
basis_bits.append(
f"HRV liegt gegenüber der älteren Referenz bei {hrv_vs_baseline_pct:+.1f} %".replace(".", ",")
)
if rhr_vs_baseline_pct is not None:
basis_bits.append(
f"Ruhepuls relativ zur Referenz bei {rhr_vs_baseline_pct:+.1f} %".replace(".", ",")
)
if basis_bits:
paras.append(
" ".join(basis_bits)
+ " (Vergleich kurzfristiges Mittel vs. ältere Basis — individuell interpretieren)."
)
rhr = series.get("resting_hr")
hrv_s = series.get("hrv")
var_bits: List[str] = []
if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 3:
var_bits.append(f"Ruhepuls Schwankungsbreite im Fenster etwa σ = {rhr['stdev']} bpm")
if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 3:
var_bits.append(f"HRV etwa σ = {hrv_s['stdev']} ms")
if var_bits:
paras.append(
"Einzelwerte können stark springen; die gestrichelte Linie zeigt einen gleitenden Mittelwert (max. 7 Messungen). "
+ "Im Fenster: " + "; ".join(var_bits) + "."
)
if rhr and rhr.get("points") and len(rhr["points"]) >= 10:
pts = rhr["points"]
last7 = [p["value"] for p in pts[-7:]]
before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else []
if before:
m7 = statistics.mean(last7)
mb = statistics.mean(before)
diff = m7 - mb
if abs(diff) > 3:
paras.append(
f"Kurzfristig liegt der Ruhepuls im Mittel der letzten 7 Messungen "
f"{'über' if diff > 0 else 'unter'} dem vorherigen Fenster (Δ ca. {abs(diff):.1f} bpm) — Kontext: Belastung, Schlaf, Stress."
)
vo2 = series.get("vo2_max")
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
s = vo2["slope_per_day"]
if s > 0.002:
paras.append(
"VO2max steigt im gewählten Fenster tendenziell — oft mit Trainingsreiz oder stabilen Messungen vereinbar."
)
elif s < -0.002:
paras.append(
"VO2max fällt im Fenster leicht — kann Pause, Krankheit oder Messrauschen widerspiegeln."
)
if r_pearson is not None and pairs_n >= 8:
if r_pearson > 0.35:
paras.append(
f"Korrelation Trainingsminuten (Tag) → Ruhepuls (Folgetag): r ≈ {r_pearson:.2f} (n = {pairs_n} Paare). "
"Höhere Belastung und etwas höherer Ruhepuls am nächsten Morgen kommen in den Daten häufig zusammen — kein Kausalbeweis."
)
elif r_pearson < -0.25:
paras.append(
f"Zwischen Tages-Belastung und Folge-Ruhepuls zeigt sich ein leicht negatives Zusammenspiel (r ≈ {r_pearson:.2f}, n = {pairs_n}). "
"Stark von Ausreißern und Datenlücken abhängig."
)
return [p for p in paras if p]
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]: def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
cur.execute( cur.execute(
""" """
@ -87,9 +175,16 @@ def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
return {r["d"]: float(r["rhr"]) for r in cur.fetchall()} return {r["d"]: float(r["rhr"]) for r in cur.fetchall()}
def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, Any]: def build_vitals_history_and_analytics(
profile_id: str,
days: int,
hrv_vs_baseline_pct: Optional[float] = None,
rhr_vs_baseline_pct: Optional[float] = None,
) -> Dict[str, Any]:
""" """
Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + Kurz-Analytik. Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + zusammengefasste Einordnung.
Optional: Abweichung HRV/Ruhepuls zur älteren Basis für einen Absatz statt doppelter KPI-Texte.
""" """
if days < 7: if days < 7:
days = 7 days = 7
@ -126,12 +221,17 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str,
dates.append(d) dates.append(d)
vals.append(fv) vals.append(fv)
if pts: if pts:
ma_vals = _trailing_window_means(vals, window=7)
points_ma7 = [
{"date": pts[i]["date"], "value": ma_vals[i]} for i in range(len(pts))
]
series[key] = { series[key] = {
"key": key, "key": key,
"label_de": label_de, "label_de": label_de,
"unit": unit, "unit": unit,
"color": color, "color": color,
"points": pts, "points": pts,
"points_ma7": points_ma7,
"n": len(pts), "n": len(pts),
"last": vals[-1] if vals else None, "last": vals[-1] if vals else None,
"mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None, "mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None,
@ -139,82 +239,6 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str,
"slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None, "slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None,
} }
bullets: List[Dict[str, Any]] = []
# VO2max-Trend
vo2 = series.get("vo2_max")
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
s = vo2["slope_per_day"]
if s > 0.002:
bullets.append(
{
"key": "vo2_trend_up",
"tone": "good",
"title": "VO2max-Verlauf",
"body": "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder besserer Datenlage vereinbar.",
}
)
elif s < -0.002:
bullets.append(
{
"key": "vo2_trend_down",
"tone": "warn",
"title": "VO2max-Verlauf",
"body": "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen entstehen; Verlauf beobachten.",
}
)
# Ruhepuls: letzte 7 vs davor (wenn genug Punkte)
rhr = series.get("resting_hr")
if rhr and rhr.get("points"):
pts = rhr["points"]
if len(pts) >= 10:
last7 = [p["value"] for p in pts[-7:]]
before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else []
if before:
m7 = statistics.mean(last7)
mb = statistics.mean(before)
diff = m7 - mb
if diff > 3:
bullets.append(
{
"key": "rhr_short_high",
"tone": "warn",
"title": "Ruhepuls zuletzt höher",
"body": f"Die letzten 7 Messungen liegen im Mittel ca. {diff:.1f} bpm über dem vorangehenden Fenster — kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen.",
}
)
elif diff < -3:
bullets.append(
{
"key": "rhr_short_low",
"tone": "good",
"title": "Ruhepuls zuletzt niedriger",
"body": "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder besserer Regeneration vereinbar (individuell).",
}
)
if rhr.get("stdev") is not None and rhr["n"] >= 6:
bullets.append(
{
"key": "rhr_var",
"tone": "neutral",
"title": "Schwankung Ruhepuls",
"body": f"Standardabweichung im Fenster ca. {rhr['stdev']} bpm — kurzfristige Schwankungen sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten.",
}
)
# HRV: Varianz-Hinweis
hrv_s = series.get("hrv")
if hrv_s and hrv_s.get("stdev") and hrv_s["n"] >= 6:
bullets.append(
{
"key": "hrv_var",
"tone": "neutral",
"title": "HRV-Schwankung",
"body": f"HRV schwankt im Fenster (σ{hrv_s['stdev']} ms). Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte.",
}
)
# Belastung (Activity) vs Ruhepuls am Folgetag # Belastung (Activity) vs Ruhepuls am Folgetag
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -234,35 +258,27 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str,
pairs_rhr.append(rhr_by_d[d1]) pairs_rhr.append(rhr_by_d[d1])
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
if r_pearson is not None: pairs_n = len(pairs_load)
if r_pearson > 0.35:
bullets.append( consolidated = _build_consolidated_paragraphs(
{ series,
"key": "load_rhr_pos", hrv_vs_baseline_pct,
"tone": "warn", rhr_vs_baseline_pct,
"title": "Belastung und Ruhepuls (Folgetag)", r_pearson,
"body": "An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis).", pairs_n,
} )
)
elif r_pearson < -0.25:
bullets.append(
{
"key": "load_rhr_neg",
"tone": "neutral",
"title": "Belastung und Ruhepuls",
"body": "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem Fenster — stark von Datenlage und Ausreißern abhängig.",
}
)
if not series: if not series:
return { return {
"chart_type": "vitals_dashboard", "chart_type": "vitals_dashboard",
"window_days": days, "window_days": days,
"series": {}, "series": {},
"analytics": {"bullets": []}, "analytics": {"bullets": [], "consolidated_paragraphs": consolidated},
"metadata": { "metadata": {
"confidence": "insufficient", "confidence": "insufficient",
"message": "Keine Vital-Zeitreihen im Fenster", "message": "Keine Vital-Zeitreihen im Fenster",
"load_rhr_pairs_n": pairs_n,
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
}, },
} }
@ -270,11 +286,14 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str,
"chart_type": "vitals_dashboard", "chart_type": "vitals_dashboard",
"window_days": days, "window_days": days,
"series": serialize_dates(series), "series": serialize_dates(series),
"analytics": {"bullets": bullets}, "analytics": {
"bullets": [],
"consolidated_paragraphs": consolidated,
},
"metadata": { "metadata": {
"confidence": "medium", "confidence": "medium",
"note": "Deskriptive Auswertung; keine medizinische Diagnose.", "note": "Deskriptive Auswertung; keine medizinische Diagnose.",
"load_rhr_pairs_n": len(pairs_load), "load_rhr_pairs_n": pairs_n,
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None, "load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
}, },
} }

View File

@ -22,7 +22,7 @@ Version: 1.0
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, List, Optional from typing import Dict, List, Optional, Set
from datetime import datetime, timedelta from datetime import datetime, timedelta
from auth import require_auth from auth import require_auth
@ -1432,11 +1432,18 @@ def get_sleep_debt_chart(
@router.get("/vital-signs-matrix") @router.get("/vital-signs-matrix")
def get_vital_signs_matrix_chart( def get_vital_signs_matrix_chart(
days: int = Query(default=7, ge=7, le=365), days: int = Query(default=7, ge=7, le=365),
session: dict = Depends(require_auth) omit_snapshot_keys: Optional[str] = Query(
default=None,
description="Optional: Komma-getrennte Keys ausblenden (z. B. resting_hr,hrv) wenn Einordnung woanders steht.",
),
session: dict = Depends(require_auth),
) -> Dict: ) -> Dict:
"""Vital signs matrix (R5).""" """Vital signs matrix (R5)."""
profile_id = session["profile_id"] profile_id = session["profile_id"]
return build_vital_signs_matrix_chart_payload(profile_id, days) omit_set: Optional[Set[str]] = None
if omit_snapshot_keys and omit_snapshot_keys.strip():
omit_set = {x.strip() for x in omit_snapshot_keys.split(",") if x.strip()}
return build_vital_signs_matrix_chart_payload(profile_id, days, omit_snapshot_keys=omit_set)
# ── Correlation Charts ────────────────────────────────────────────────────── # ── Correlation Charts ──────────────────────────────────────────────────────

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts'
import { api } from '../utils/api' import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview' import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor, getStatusBg } from '../utils/interpret' import { getStatusColor, getStatusBg } from '../utils/interpret'
@ -311,13 +311,39 @@ export default function RecoveryDashboardOverview({
} }
const series = vh.series || {} const series = vh.series || {}
const keys = Object.keys(series) const keys = Object.keys(series)
const paragraphs = vh.analytics?.consolidated_paragraphs || []
const bullets = vh.analytics?.bullets || [] const bullets = vh.analytics?.bullets || []
const corrNote = vh.metadata?.load_rhr_correlation const showParagraphs = paragraphs.length > 0
const pairsN = vh.metadata?.load_rhr_pairs_n const showBulletsFallback = !showParagraphs && bullets.length > 0
return ( return (
<div style={{ width: '100%', minWidth: 0 }}> <div style={{ width: '100%', minWidth: 0 }}>
{bullets.length > 0 ? ( {showParagraphs ? (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Einordnung (Vital & Belastung)
</div>
<div
style={{
borderRadius: 8,
padding: '12px 14px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: 12,
color: 'var(--text2)',
lineHeight: 1.55,
}}
>
{paragraphs.map((text, i) => (
<p key={i} style={{ margin: i === 0 ? 0 : '10px 0 0' }}>
{text}
</p>
))}
</div>
</div>
) : null}
{showBulletsFallback ? (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Einordnung (Vital & Belastung) Einordnung (Vital & Belastung)
@ -339,31 +365,34 @@ export default function RecoveryDashboardOverview({
</div> </div>
))} ))}
</div> </div>
{corrNote != null && pairsN != null ? (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)' }}>
Korrelation Trainingsminuten (Tag) Ruhepuls (Folgetag): r {corrNote} (n = {pairsN} Paare)
</div>
) : null}
</div> </div>
) : null} ) : null}
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}> <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
Je Kennzahl eigene Skala (physische Einheit). Verlauf sinnvoll ab ca. 23 Messpunkten. Je Kennzahl eigene Skala (physische Einheit). Gestrichelt: gleitender Mittelwert (max. 7 aufeinanderfolgende
Messungen), glättet starke Einzelschwankungen.
</div> </div>
{keys.map((k) => { {keys.map((k) => {
const m = series[k] const m = series[k]
const pts = m.points || [] const pts = m.points || []
const maPts = m.points_ma7 || []
if (pts.length === 0) return null if (pts.length === 0) return null
const chartData = pts.map((p) => ({ const chartData = pts.map((p, i) => ({
...p, ...p,
d: fmtDate(p.date), d: fmtDate(p.date),
value_ma: maPts[i]?.value != null ? maPts[i].value : null,
})) }))
const vals = pts.map((p) => p.value) const vals = []
pts.forEach((p, i) => {
vals.push(p.value)
if (maPts[i]?.value != null) vals.push(maPts[i].value)
})
const mn = Math.min(...vals) const mn = Math.min(...vals)
const mx = Math.max(...vals) const mx = Math.max(...vals)
const span = mx - mn const span = mx - mn
const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : span * 0.12 const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : span * 0.12
const hasMa = maPts.length > 0 && maPts.some((x) => x?.value != null)
return ( return (
<div key={k} style={{ marginBottom: 16, width: '100%' }}> <div key={k} style={{ marginBottom: 16, width: '100%' }}>
@ -381,7 +410,7 @@ export default function RecoveryDashboardOverview({
Ein Messpunkt ({m.last}) weiter erfassen, um einen Verlauf zu sehen. Ein Messpunkt ({m.last}) weiter erfassen, um einen Verlauf zu sehen.
</div> </div>
) : ( ) : (
<div style={{ width: '100%', height: 200, minHeight: 200 }}> <div style={{ width: '100%', height: hasMa ? 220 : 200, minHeight: hasMa ? 220 : 200 }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}> <LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
@ -399,14 +428,28 @@ export default function RecoveryDashboardOverview({
fontSize: 11, fontSize: 11,
}} }}
/> />
{hasMa ? <Legend wrapperStyle={{ fontSize: 10, paddingTop: 4 }} /> : null}
<Line <Line
type="monotone" type="monotone"
dataKey="value" dataKey="value"
stroke={m.color || '#1D9E75'} stroke={m.color || '#1D9E75'}
strokeWidth={2} strokeWidth={2}
dot={{ r: 3 }} dot={{ r: 3 }}
name={m.label_de} name="Messwert"
/> />
{hasMa ? (
<Line
type="monotone"
dataKey="value_ma"
stroke={m.color || '#1D9E75'}
strokeOpacity={0.55}
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
name="Ø (max. 7 Messungen)"
connectNulls
/>
) : null}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -444,9 +487,17 @@ export default function RecoveryDashboardOverview({
const vitDate = meta.vitals_measured_at const vitDate = meta.vitals_measured_at
const bpDate = meta.blood_pressure_measured_at const bpDate = meta.blood_pressure_measured_at
const disclaimer = meta.disclaimer_de const disclaimer = meta.disclaimer_de
const hasRhrCard = items.some((it) => it.key === 'resting_hr')
const hasHrvCard = items.some((it) => it.key === 'hrv')
return ( return (
<> <>
{items.length > 0 && !hasRhrCard && !hasHrvCard ? (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Ruhepuls und HRV sind in diesem Bereich bewusst nicht noch einmal als Zonen-Karten geführt die Einordnung
steht oben im Vital-Verlauf und in der KPI-Kachel Herz & autonomes System.
</p>
) : null}
{items.length > 0 ? ( {items.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
{items.map((it) => { {items.map((it) => {