feat: enhance recovery dashboard with vital signs analytics and visualization improvements
- 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:
parent
e7bcdc3228
commit
8cb5ad992f
|
|
@ -7,7 +7,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
|
|||
from __future__ import annotations
|
||||
|
||||
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 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)
|
||||
|
||||
|
||||
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)."""
|
||||
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:
|
||||
|
|
@ -464,7 +471,11 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s
|
|||
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)
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ def build_recovery_dashboard_kpi_tiles(
|
|||
avg_sleep_hours: Optional[float],
|
||||
hrv_vs_baseline_pct: Optional[float],
|
||||
rhr_vs_baseline_pct: Optional[float],
|
||||
merge_heart_autonomic_tiles: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
tiles: List[Dict[str, Any]] = []
|
||||
|
||||
|
|
@ -93,6 +94,36 @@ def build_recovery_dashboard_kpi_tiles(
|
|||
}
|
||||
)
|
||||
|
||||
if merge_heart_autonomic_tiles and (
|
||||
hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None
|
||||
):
|
||||
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"
|
||||
)
|
||||
parts: List[str] = []
|
||||
if hrv_vs_baseline_pct is not None:
|
||||
parts.append(f"HRV {hrv_vs_baseline_pct:+.1f} %".replace(".", ","))
|
||||
if rhr_vs_baseline_pct is not None:
|
||||
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
|
||||
|
|
@ -141,7 +172,9 @@ def build_recovery_progress_insights(
|
|||
recovery_score: Optional[int],
|
||||
sleep_debt_hours: Optional[float],
|
||||
hrv_vs_baseline_pct: Optional[float],
|
||||
include_autonomic_hrv_narrative: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""HRV-Basistext optional: steckt gebündelt im Vital-Verlauf (consolidated_paragraphs)."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
|
||||
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"
|
||||
out.append(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
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 = {
|
||||
"recovery_score": build_recovery_score_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_debt": build_sleep_debt_chart_payload(profile_id, chart_days),
|
||||
"vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days),
|
||||
"vitals_history": build_vitals_history_and_analytics(profile_id, vital_days),
|
||||
"vital_signs_matrix": build_vital_signs_matrix_chart_payload(
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Keine Diagnose — typische Referenzbereiche für UI/Coaching.
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -104,20 +104,23 @@ def assess_vo2_max(value: float) -> tuple:
|
|||
def build_vital_items_from_rows(
|
||||
vitals_row: Optional[Dict[str, Any]],
|
||||
bp_row: Optional[Dict[str, Any]],
|
||||
omit_keys: Optional[Set[str]] = None,
|
||||
) -> 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]] = []
|
||||
order = 0
|
||||
|
||||
if vitals_row:
|
||||
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)
|
||||
t, z, h = assess_resting_hr(v)
|
||||
items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order))
|
||||
order += 1
|
||||
|
||||
hrv = vitals_row.get("hrv")
|
||||
if hrv is not None:
|
||||
if hrv is not None and "hrv" not in skip:
|
||||
v = safe_float(hrv)
|
||||
t, z, h = assess_hrv_ms(v)
|
||||
items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import statistics
|
||||
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 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}
|
||||
|
||||
|
||||
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]:
|
||||
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()}
|
||||
|
||||
|
||||
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:
|
||||
days = 7
|
||||
|
|
@ -126,12 +221,17 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str,
|
|||
dates.append(d)
|
||||
vals.append(fv)
|
||||
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] = {
|
||||
"key": key,
|
||||
"label_de": label_de,
|
||||
"unit": unit,
|
||||
"color": color,
|
||||
"points": pts,
|
||||
"points_ma7": points_ma7,
|
||||
"n": len(pts),
|
||||
"last": vals[-1] if vals 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,
|
||||
}
|
||||
|
||||
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
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
|
@ -234,24 +258,14 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str,
|
|||
pairs_rhr.append(rhr_by_d[d1])
|
||||
|
||||
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
|
||||
if r_pearson is not None:
|
||||
if r_pearson > 0.35:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "load_rhr_pos",
|
||||
"tone": "warn",
|
||||
"title": "Belastung und Ruhepuls (Folgetag)",
|
||||
"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).",
|
||||
}
|
||||
)
|
||||
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.",
|
||||
}
|
||||
pairs_n = len(pairs_load)
|
||||
|
||||
consolidated = _build_consolidated_paragraphs(
|
||||
series,
|
||||
hrv_vs_baseline_pct,
|
||||
rhr_vs_baseline_pct,
|
||||
r_pearson,
|
||||
pairs_n,
|
||||
)
|
||||
|
||||
if not series:
|
||||
|
|
@ -259,10 +273,12 @@ def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str,
|
|||
"chart_type": "vitals_dashboard",
|
||||
"window_days": days,
|
||||
"series": {},
|
||||
"analytics": {"bullets": []},
|
||||
"analytics": {"bullets": [], "consolidated_paragraphs": consolidated},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"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",
|
||||
"window_days": days,
|
||||
"series": serialize_dates(series),
|
||||
"analytics": {"bullets": bullets},
|
||||
"analytics": {
|
||||
"bullets": [],
|
||||
"consolidated_paragraphs": consolidated,
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "medium",
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Version: 1.0
|
|||
"""
|
||||
|
||||
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 auth import require_auth
|
||||
|
|
@ -1432,11 +1432,18 @@ def get_sleep_debt_chart(
|
|||
@router.get("/vital-signs-matrix")
|
||||
def get_vital_signs_matrix_chart(
|
||||
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:
|
||||
"""Vital signs matrix (R5)."""
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
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 KpiTilesOverview from './KpiTilesOverview'
|
||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
|
|
@ -311,13 +311,39 @@ export default function RecoveryDashboardOverview({
|
|||
}
|
||||
const series = vh.series || {}
|
||||
const keys = Object.keys(series)
|
||||
const paragraphs = vh.analytics?.consolidated_paragraphs || []
|
||||
const bullets = vh.analytics?.bullets || []
|
||||
const corrNote = vh.metadata?.load_rhr_correlation
|
||||
const pairsN = vh.metadata?.load_rhr_pairs_n
|
||||
const showParagraphs = paragraphs.length > 0
|
||||
const showBulletsFallback = !showParagraphs && bullets.length > 0
|
||||
|
||||
return (
|
||||
<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={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
Einordnung (Vital & Belastung)
|
||||
|
|
@ -339,31 +365,34 @@ export default function RecoveryDashboardOverview({
|
|||
</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>
|
||||
) : null}
|
||||
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
Je Kennzahl eigene Skala (physische Einheit). Verlauf sinnvoll ab ca. 2–3 Messpunkten.
|
||||
Je Kennzahl eigene Skala (physische Einheit). Gestrichelt: gleitender Mittelwert (max. 7 aufeinanderfolgende
|
||||
Messungen), glättet starke Einzelschwankungen.
|
||||
</div>
|
||||
|
||||
{keys.map((k) => {
|
||||
const m = series[k]
|
||||
const pts = m.points || []
|
||||
const maPts = m.points_ma7 || []
|
||||
if (pts.length === 0) return null
|
||||
const chartData = pts.map((p) => ({
|
||||
const chartData = pts.map((p, i) => ({
|
||||
...p,
|
||||
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 mx = Math.max(...vals)
|
||||
const span = mx - mn
|
||||
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 (
|
||||
<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.
|
||||
</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%">
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
|
|
@ -399,14 +428,28 @@ export default function RecoveryDashboardOverview({
|
|||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
{hasMa ? <Legend wrapperStyle={{ fontSize: 10, paddingTop: 4 }} /> : null}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={m.color || '#1D9E75'}
|
||||
strokeWidth={2}
|
||||
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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
|
@ -444,9 +487,17 @@ export default function RecoveryDashboardOverview({
|
|||
const vitDate = meta.vitals_measured_at
|
||||
const bpDate = meta.blood_pressure_measured_at
|
||||
const disclaimer = meta.disclaimer_de
|
||||
const hasRhrCard = items.some((it) => it.key === 'resting_hr')
|
||||
const hasHrvCard = items.some((it) => it.key === 'hrv')
|
||||
|
||||
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 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
|
||||
{items.map((it) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user