Merge pull request 'Neue Aufbereitung Fitness Verlauf' (#97) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

Reviewed-on: #97
This commit is contained in:
Lars 2026-04-20 11:47:52 +02:00
commit a1b458d228
7 changed files with 942 additions and 268 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,8 @@ 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,
include_avg_sleep_kpi: bool = True,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
tiles: List[Dict[str, Any]] = [] tiles: List[Dict[str, Any]] = []
@ -78,61 +80,92 @@ def build_recovery_dashboard_kpi_tiles(
} }
) )
tiles.append( if include_avg_sleep_kpi:
{ tiles.append(
"key": "avg_sleep", {
"category": "Ø Schlafdauer", "key": "avg_sleep",
"icon": "🌙", "category": "Ø Schlafdauer",
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "", "icon": "🌙",
"sublabel": "Im gewählten Fenster", "value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "",
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn", "sublabel": "Im gewählten Fenster",
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis", "status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
"hoverTop": "Durchschnittliche Schlafdauer", "verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
"hoverBody": "get_sleep_duration_data", "hoverTop": "Durchschnittliche Schlafdauer",
"keys": ["sleep_duration_avg"], "hoverBody": "get_sleep_duration_data",
} "keys": ["sleep_duration_avg"],
) }
)
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 +174,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 +203,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

@ -14,6 +14,7 @@ from data_layer.recovery_chart_payloads import (
build_sleep_duration_quality_chart_payload, build_sleep_duration_quality_chart_payload,
build_vital_signs_matrix_chart_payload, build_vital_signs_matrix_chart_payload,
) )
from data_layer.vitals_fitness_insights import build_vitals_history_and_analytics
from data_layer.recovery_interpretation import ( from data_layer.recovery_interpretation import (
build_recovery_dashboard_kpi_tiles, build_recovery_dashboard_kpi_tiles,
build_recovery_progress_insights, build_recovery_progress_insights,
@ -74,6 +75,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
avg_sleep, avg_sleep,
float(hrv_dev) if hrv_dev is not None else None, float(hrv_dev) if hrv_dev is not None else None,
float(rhr_dev) if rhr_dev is not None else None, float(rhr_dev) if rhr_dev is not None else None,
include_avg_sleep_kpi=False,
) )
insights = build_recovery_progress_insights( insights = build_recovery_progress_insights(
@ -82,12 +84,18 @@ 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(profile_id, vital_days),
"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

@ -0,0 +1,400 @@
"""
Vitalwerte: Zeitreihen + einfache Fitness-/Recovery-Einordnung (Layer 1, Issue 53).
Keine Diagnose deskriptive Trends, Korrelationen und Varianz-Hinweise.
"""
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence
from db import get_db, get_cursor
from data_layer.utils import safe_float, serialize_dates
SERIES_CONFIG = (
("resting_hr", "Ruhepuls", "bpm", "#3B82F6"),
("hrv", "HRV", "ms", "#1D9E75"),
("vo2_max", "VO2max", "ml/kg/min", "#8B5CF6"),
("spo2", "SpO2", "%", "#0EA5E9"),
("respiratory_rate", "Atemfrequenz", "/min", "#F59E0B"),
)
def _date_to_ord(d: Any) -> float:
if hasattr(d, "toordinal"):
return float(d.toordinal())
if isinstance(d, str):
return float(datetime.fromisoformat(d[:10]).date().toordinal())
return 0.0
def _linear_slope(dates: Sequence[Any], values: Sequence[float]) -> float:
if len(values) < 3 or len(dates) != len(values):
return 0.0
xs = [_date_to_ord(d) for d in dates]
ys = list(values)
n = len(xs)
mx = sum(xs) / n
my = sum(ys) / n
den = sum((x - mx) ** 2 for x in xs)
if den < 1e-9:
return 0.0
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den
def _pearson(xs: Sequence[float], ys: Sequence[float]) -> Optional[float]:
n = len(xs)
if n < 5 or len(ys) != n:
return None
mx = statistics.mean(xs)
my = statistics.mean(ys)
sx = statistics.pstdev(xs) if n > 1 else 0.0
sy = statistics.pstdev(ys) if n > 1 else 0.0
if sx < 1e-9 or sy < 1e-9:
return None
cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / n
return cov / (sx * sy)
def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
"""Summe Trainingsminuten pro Kalendertag als Belastungs-Proxy."""
cur.execute(
"""
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
FROM activity_log
WHERE profile_id = %s AND date >= %s::date AND duration_min IS NOT NULL AND duration_min > 0
GROUP BY date
ORDER BY date
""",
(profile_id, cutoff),
)
rows = cur.fetchall()
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 _de_num(x: float) -> str:
"""Dezimalzahl mit Komma für Fließtext."""
return f"{x:.1f}".replace(".", ",")
def _de_num_signed(x: float) -> str:
"""Wie _de_num, mit explizitem Vorzeichen (für %-Abweichungen)."""
return f"{x:+.1f}".replace(".", ",")
def _ins(
key: str,
section: str,
title_de: str,
body: str,
tone: str = "neutral",
) -> Dict[str, Any]:
"""Ein strukturierter Hinweis für UI-Platzierung (section: heart | vo2)."""
return {"key": key, "section": section, "title_de": title_de, "body": body, "tone": tone}
def _build_section_insights(
series: Dict[str, Any],
hrv_vs_baseline_pct: Optional[float],
rhr_vs_baseline_pct: Optional[float],
r_pearson: Optional[float],
pairs_n: int,
) -> List[Dict[str, Any]]:
"""
Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt.
section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf.
"""
out: List[Dict[str, Any]] = []
basis_bits: List[str] = []
if hrv_vs_baseline_pct is not None:
basis_bits.append(
f"HRV gegenüber älterer Referenz: {_de_num_signed(float(hrv_vs_baseline_pct))} %"
)
if rhr_vs_baseline_pct is not None:
basis_bits.append(
f"Ruhepuls relativ zur Referenz: {_de_num_signed(float(rhr_vs_baseline_pct))} %"
)
if basis_bits:
out.append(
_ins(
"heart_baseline",
"heart",
"Kurzfristiges Mittel vs. ältere Basis",
" ".join(basis_bits)
+ " — Vergleich letzter Tage zum älteren Referenzmittel; individuell interpretieren (keine Diagnose).",
"neutral",
)
)
rhr = series.get("resting_hr")
hrv_s = series.get("hrv")
rhr_short_body = ""
r_short_tone = "neutral"
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 diff > 3:
rhr_short_body = (
f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — "
"kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen."
)
r_short_tone = "warn"
elif diff < -3:
rhr_short_body = (
"Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder "
"besserer Regeneration vereinbar (individuell)."
)
r_short_tone = "good"
rhr_var_sentence = ""
if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6:
rhr_var_sentence = (
f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen "
"sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
)
hrv_var_sentence = ""
if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6:
hrv_var_sentence = (
f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — "
"Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte."
)
ma_hint = (
"Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert "
"über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)."
)
streuung_parts: List[str] = [ma_hint]
if rhr_var_sentence:
streuung_parts.append(rhr_var_sentence)
if hrv_var_sentence:
streuung_parts.append(hrv_var_sentence)
if rhr or hrv_s:
out.append(
_ins(
"heart_streuung_ma",
"heart",
"Streuung & gleitender Mittelwert",
" ".join(streuung_parts),
"neutral",
)
)
if rhr_short_body:
out.append(_ins("heart_rhr_kurz", "heart", "Ruhepuls: Kurzvergleich", rhr_short_body, r_short_tone))
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:
out.append(
_ins(
"vo2_trend_up",
"vo2",
"VO2max-Verlauf",
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
"besserer Datenlage vereinbar.",
"good",
)
)
elif s < -0.002:
out.append(
_ins(
"vo2_trend_down",
"vo2",
"VO2max-Verlauf",
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
"entstehen; Verlauf beobachten.",
"warn",
)
)
if r_pearson is not None and pairs_n >= 8:
if r_pearson > 0.35:
out.append(
_ins(
"heart_load_rhr",
"heart",
"Training und Folge-Ruhepuls",
(
"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). "
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren."
),
"warn",
)
)
elif r_pearson < -0.25:
out.append(
_ins(
"heart_load_rhr_neg",
"heart",
"Training und Folge-Ruhepuls",
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem "
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare.",
"neutral",
)
)
return out
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
cur.execute(
"""
SELECT date::text AS d, resting_hr::float AS rhr
FROM vitals_baseline
WHERE profile_id = %s AND date >= %s::date AND resting_hr IS NOT NULL
ORDER BY date
""",
(profile_id, cutoff),
)
return {r["d"]: float(r["rhr"]) for r in cur.fetchall()}
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) + zusammengefasste Einordnung.
Optional: Abweichung HRV/Ruhepuls zur älteren Basis für einen Absatz statt doppelter KPI-Texte.
"""
if days < 7:
days = 7
if days > 365:
days = 365
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, vo2_max, spo2, respiratory_rate
FROM vitals_baseline
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
rows = cur.fetchall()
series: Dict[str, Any] = {}
for key, label_de, unit, color in SERIES_CONFIG:
pts: List[Dict[str, Any]] = []
dates: List[Any] = []
vals: List[float] = []
for r in rows:
v = r.get(key)
if v is None:
continue
fv = safe_float(v)
d = r["date"]
d_iso = d.isoformat() if hasattr(d, "isoformat") else str(d)[:10]
pts.append({"date": d_iso, "value": round(fv, 2)})
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,
"stdev": round(statistics.pstdev(vals), 2) if len(vals) >= 2 else None,
"slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None,
}
# Belastung (Activity) vs Ruhepuls am Folgetag
with get_db() as conn:
cur = get_cursor(conn)
load_by_d = _daily_training_load(cur, profile_id, cutoff)
rhr_by_d = _rhr_by_date(cur, profile_id, cutoff)
pairs_load: List[float] = []
pairs_rhr: List[float] = []
for d_str, load_min in load_by_d.items():
try:
d0 = datetime.fromisoformat(d_str[:10]).date()
except ValueError:
continue
d1 = (d0 + timedelta(days=1)).isoformat()
if d1 in rhr_by_d and load_min > 0:
pairs_load.append(load_min)
pairs_rhr.append(rhr_by_d[d1])
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
pairs_n = len(pairs_load)
section_insights = _build_section_insights(
series,
hrv_vs_baseline_pct,
rhr_vs_baseline_pct,
r_pearson,
pairs_n,
)
if not series:
return {
"chart_type": "vitals_dashboard",
"window_days": days,
"series": {},
"analytics": {
"bullets": [],
"consolidated_paragraphs": [],
"section_insights": section_insights,
},
"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,
},
}
return {
"chart_type": "vitals_dashboard",
"window_days": days,
"series": serialize_dates(series),
"analytics": {
"bullets": [],
"consolidated_paragraphs": [],
"section_insights": section_insights,
},
"metadata": {
"confidence": "medium",
"note": "Deskriptive Auswertung; keine medizinische Diagnose.",
"load_rhr_pairs_n": pairs_n,
"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,17 +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 { import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts'
LineChart,
Line,
BarChart,
Bar,
Cell,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} 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'
@ -19,25 +8,177 @@ import dayjs from 'dayjs'
const fmtDate = (d) => dayjs(d).format('DD.MM.') const fmtDate = (d) => dayjs(d).format('DD.MM.')
function vitalToneToUi(tone) { /** Nur diese Kennzahlen als eigene Verläufe — Ruhepuls/HRV nur im kombinierten Diagramm (keine Doppelung). */
if (tone === 'good') return 'good' const VITAL_TREND_ONLY_KEYS = ['vo2_max', 'spo2', 'respiratory_rate']
if (tone === 'bad') return 'bad'
if (tone === 'neutral') return 'neutral' function formatAxisTick(v) {
return 'warn' const n = Number(v)
if (!Number.isFinite(n)) return ''
const a = Math.abs(n)
if (a >= 100) return String(Math.round(n))
if (a >= 10) return n.toFixed(1)
return Number(n.toFixed(2)).toString()
} }
function barFillForTone(tone) { function insightBulletStripe(tone) {
const ui = vitalToneToUi(tone) if (tone === 'good') return getStatusColor('good')
if (ui === 'good') return '#1D9E75' if (tone === 'bad') return getStatusColor('bad')
if (ui === 'bad') return '#D85A30' if (tone === 'neutral') return '#6B7280'
if (ui === 'neutral') return '#6B7280' return getStatusColor('warn')
return '#EF9F27'
} }
function ChartCard({ title, loading, error, children }) { function SectionHeading({ title, hint, compactTop }) {
return (
<div style={{ marginTop: compactTop ? 8 : 20, marginBottom: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)' }}>{title}</div>
{hint ? (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4, lineHeight: 1.45 }}>{hint}</div>
) : null}
</div>
)
}
function VitalZoneHint({ item }) {
if (!item) return null
const stripe = insightBulletStripe(item.tone)
const t = item.tone
const hintBg =
t === 'good' ? getStatusBg('good') : t === 'bad' ? getStatusBg('bad') : t === 'warn' ? getStatusBg('warn') : 'var(--surface2)'
return (
<div
style={{
marginBottom: 10,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: hintBg,
fontSize: 11,
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
<span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Letzte Einordnung (Snapshot):</span>
<span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span>
<span style={{ marginLeft: 6 }}>{item.hint_de}</span>
</div>
)
}
/** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */
function HeartAutonomicGuide() {
return (
<details style={{ marginBottom: 12, fontSize: 11, color: 'var(--text2)' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text3)', listStyle: 'none' }}>
Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm
</summary>
<div style={{ marginTop: 8, lineHeight: 1.5, paddingLeft: 2 }}>
<p style={{ margin: '0 0 8px' }}>
Es handelt sich um <strong>Abweichungen in %</strong> vom älteren Referenzmittel (kurzfristiges Mittel vs. längere
Basis) nicht um absolute Normalwerte.
</p>
<ul style={{ margin: 0, paddingLeft: 18 }}>
<li>
<strong>HRV</strong>: Positive % = zuletzt oft über der älteren Basis; sehr personenabhängig.
</li>
<li>
<strong>Ruhepuls</strong>: Negative % = niedriger als die Referenz; bei Training oft unkritisch günstig.
</li>
</ul>
<p style={{ margin: '8px 0 0' }}>
Das Liniendiagramm zeigt die Rohverläufe; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert
anzeigen.
</p>
</div>
</details>
)
}
function SectionInsightCard({ ins }) {
const t = ['good', 'warn', 'bad', 'neutral'].includes(ins.tone) ? ins.tone : 'neutral'
const stripe = insightBulletStripe(t)
const bg =
t === 'good' ? getStatusBg('good') : t === 'bad' ? getStatusBg('bad') : t === 'warn' ? getStatusBg('warn') : 'var(--surface2)'
return (
<div
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: bg,
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4, color: 'var(--text1)' }}>{ins.title_de}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
</div>
)
}
function SnapshotCards({ items }) {
if (!items?.length) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 12 }}>
{items.map((it) => {
const stripe =
it.tone === 'good'
? getStatusColor('good')
: it.tone === 'bad'
? getStatusColor('bad')
: it.tone === 'warn'
? getStatusColor('warn')
: '#6B7280'
const bg =
it.tone === 'good'
? getStatusBg('good')
: it.tone === 'bad'
? getStatusBg('bad')
: it.tone === 'warn'
? getStatusBg('warn')
: 'var(--surface2)'
return (
<div
key={it.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: bg,
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>{it.label_de}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{it.value_display}</span>
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
background: 'var(--surface)',
color: stripe,
border: `1px solid ${stripe}`,
}}
>
{it.zone_label_de}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45 }}>{it.hint_de}</div>
</div>
)
})}
</div>
)
}
function ChartCard({ title, loading, error, children, description }) {
return ( return (
<div className="card" style={{ marginBottom: 12 }}> <div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>{title}</div> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: description ? 4 : 8 }}>{title}</div>
{description ? (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>{description}</div>
) : null}
{loading && ( {loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}> <div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" style={{ width: 32, height: 32 }} /> <div className="spinner" style={{ width: 32, height: 32 }} />
@ -127,6 +268,17 @@ export default function RecoveryDashboardOverview({
const sleepData = viz.charts?.sleep_duration_quality const sleepData = viz.charts?.sleep_duration_quality
const debtData = viz.charts?.sleep_debt const debtData = viz.charts?.sleep_debt
const vitalsData = viz.charts?.vital_signs_matrix const vitalsData = viz.charts?.vital_signs_matrix
const vitalsHistory = viz.charts?.vitals_history
const vitalItemsByKey = {}
;(vitalsData?.metadata?.vital_items || []).forEach((it) => {
vitalItemsByKey[it.key] = it
})
const sectionInsights = vitalsHistory?.analytics?.section_insights || []
const heartSectionInsights = sectionInsights.filter((s) => s.section === 'heart')
const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
...t, ...t,
@ -163,7 +315,14 @@ export default function RecoveryDashboardOverview({
tickLine={false} tickLine={false}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
/> />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} /> <YAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
domain={[0, 100]}
tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')}
tickCount={6}
width={36}
/>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
background: 'var(--surface)', background: 'var(--surface)',
@ -206,8 +365,23 @@ export default function RecoveryDashboardOverview({
tickLine={false} tickLine={false}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
/> />
<YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} /> <YAxis
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} /> yAxisId="left"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
background: 'var(--surface)', background: 'var(--surface)',
@ -315,168 +489,129 @@ export default function RecoveryDashboardOverview({
) )
} }
const renderVitalSigns = () => { /** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */
if (!vitalsData) { const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => {
const vh = vitalsHistory
if (!vh) {
return <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
}
if (vh.metadata?.confidence === 'insufficient') {
return ( return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}> <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)', lineHeight: 1.5 }}>
Keine Vital-Matrix-Daten {vh.metadata?.message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.'}
</div> </div>
) )
} }
const meta = vitalsData.metadata || {} const series = vh.series || {}
const items = meta.vital_items || [] const keys = VITAL_TREND_ONLY_KEYS.filter((k) => series[k]?.points?.length)
const ds0 = vitalsData.data?.datasets?.[0] if (keys.length === 0) {
const hasRawChart =
Array.isArray(vitalsData.data?.labels) &&
vitalsData.data.labels.length > 0 &&
Array.isArray(ds0?.data) &&
ds0.data.length > 0
const ins = meta.confidence === 'insufficient'
if (ins && items.length === 0 && !hasRawChart) {
return ( return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}> <div style={{ padding: 8, fontSize: 12, color: 'var(--text3)' }}>
{meta.message || 'Keine aktuellen Vitalwerte'} Keine zusätzlichen Vital-Verläufe (VO2max, SpO2, Atemfrequenz) im Fenster oder nur Ruhepuls/HRV erfasst.
</div> </div>
) )
} }
let chartRows = items.map((it) => ({
name: it.label_de,
value: Number(it.bar_value ?? 0),
fill: barFillForTone(it.tone),
tone: it.tone,
}))
if (chartRows.length === 0 && hasRawChart) {
const bg = ds0.backgroundColor
chartRows = vitalsData.data.labels.map((name, i) => ({
name,
value: Number(ds0.data[i] ?? 0),
fill: Array.isArray(bg) ? bg[i] || '#1D9E75' : bg || '#1D9E75',
tone: 'neutral',
}))
}
if (items.length === 0 && chartRows.length === 0) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten).
</div>
)
}
const vitDate = meta.vitals_measured_at
const bpDate = meta.blood_pressure_measured_at
const disclaimer = meta.disclaimer_de
return ( return (
<> <div style={{ width: '100%', minWidth: 0 }}>
{items.length > 0 ? ( {vo2Insights.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{items.map((it) => { {vo2Insights.map((ins) => (
const stripe = <SectionInsightCard key={ins.key} ins={ins} />
it.tone === 'good' ))}
? getStatusColor('good')
: it.tone === 'bad'
? getStatusColor('bad')
: it.tone === 'warn'
? getStatusColor('warn')
: '#6B7280'
const bg =
it.tone === 'good'
? getStatusBg('good')
: it.tone === 'bad'
? getStatusBg('bad')
: it.tone === 'warn'
? getStatusBg('warn')
: 'var(--surface2)'
return (
<div
key={it.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: bg,
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>{it.label_de}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{it.value_display}</span>
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
background: 'var(--surface)',
color: stripe,
border: `1px solid ${stripe}`,
}}
>
{it.zone_label_de}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45 }}>{it.hint_de}</div>
</div>
)
})}
</div> </div>
) : null} ) : null}
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
{items.length === 0 && chartRows.length > 0 ? ( Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}> begrenzt.
Diagramm aus Server-Daten (ohne Zonen-Detail bitte App aktualisieren oder Cache leeren).
</div>
) : null}
{chartRows.length > 0 ? (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>
Relative Einordnung (0100, nur Übersicht keine körperliche Messgröße)
</div>
<ResponsiveContainer width="100%" height={Math.max(200, chartRows.length * 36)}>
<BarChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 8 }} layout="horizontal">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={100} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']}
/>
<Bar dataKey="value" name="Einordnung" radius={[0, 3, 3, 0]}>
{chartRows.map((row, i) => (
<Cell key={`c-${i}`} fill={row.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</>
) : null}
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
{vitDate ? (
<>
Baseline-Vitals Stand: <strong>{fmtDate(vitDate)}</strong>
</>
) : null}
{vitDate && bpDate ? ' · ' : null}
{bpDate ? (
<>
Blutdruck Stand: <strong>{fmtDate(bpDate)}</strong>
</>
) : null}
{!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage</> : null}
</div> </div>
{disclaimer ? ( {keys.map((k) => {
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div> const m = series[k]
) : null} const pts = m.points || []
</> const maPts = m.points_ma7 || []
const zoneItem = vitalItemsByKey[k]
const chartData = pts.map((p, i) => ({
...p,
d: fmtDate(p.date),
value_ma: maPts[i]?.value != null ? maPts[i].value : null,
}))
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) : Math.max(span * 0.12, 0.01)
const hasMa = maPts.length > 0 && maPts.some((x) => x?.value != null)
return (
<div key={k} style={{ marginBottom: 18, width: '100%' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 6 }}>
{m.label_de} ({m.unit})
{m.n != null ? <span style={{ fontWeight: 400, color: 'var(--text3)' }}> · n = {m.n}</span> : null}
{m.mean != null ? (
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> · Ø {formatAxisTick(m.mean)}</span>
) : null}
</div>
<VitalZoneHint item={zoneItem} />
{pts.length === 1 ? (
<div style={{ fontSize: 12, color: 'var(--text3)', padding: '8px 0' }}>
Ein Messpunkt ({formatAxisTick(m.last)}) weiter erfassen, um einen Verlauf zu sehen.
</div>
) : (
<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" />
<XAxis dataKey="d" tick={{ fontSize: 9, fill: 'var(--text3)' }} interval="preserveStartEnd" />
<YAxis
domain={[mn - pad, mx + pad]}
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickFormatter={formatAxisTick}
tickCount={6}
width={48}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(value) => (value != null ? formatAxisTick(value) : '')}
/>
{hasMa ? <Legend wrapperStyle={{ fontSize: 10, paddingTop: 4 }} /> : null}
<Line
type="monotone"
dataKey="value"
stroke={m.color || '#1D9E75'}
strokeWidth={2}
dot={{ r: 3 }}
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>
)}
</div>
)
})}
</div>
) )
} }
@ -504,43 +639,118 @@ export default function RecoveryDashboardOverview({
) : null} ) : null}
</div> </div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}> <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. <strong>{eff}</strong> Tage · Charts{' '} Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage ·
<strong>{cDays}</strong> Tage · Vital-Matrix <strong>{vDays}</strong> Tage. Vital-Snapshot <strong>{vDays}</strong> Tage.
</p> </p>
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" /> <KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
{insights.length > 0 ? ( {insights.length > 0 ? (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> Überblick: Recovery & Schlaf
{insights.map((ins) => ( </div>
<div <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
key={ins.key} {insights.map((ins) => {
style={{ const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn'
borderRadius: 8, return (
padding: '10px 12px', <div
border: '1px solid var(--border)', key={ins.key}
borderLeft: `4px solid ${getStatusColor(['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn')}`, style={{
background: 'var(--surface2)', borderRadius: 8,
}} padding: '10px 12px',
> border: '1px solid var(--border)',
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{ins.title}</div> borderLeft: `4px solid ${getStatusColor(t)}`,
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div> background: getStatusBg(t),
</div> }}
))} >
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{ins.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
</div>
)
})}
</div> </div>
</div> </div>
) : null} ) : null}
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>Diagramme</div> <SectionHeading
compactTop
title="Schlaf & Erholung"
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
/>
<ChartCard
title="Recovery Score"
description="0100, Verlauf im Chart-Fenster. Höher ist in der Regel günstiger."
>
{renderRecoveryScore()}
</ChartCard>
<ChartCard
title="Schlaf: Dauer & Qualität"
description={
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null
? `Dauer (h) und Qualitätsanteil (%). Mittlere Schlafdauer im Chart-Fenster: ${sleepData.metadata.avg_duration_hours} h — gleiche Information wie früher in der KPI «Ø Schlafdauer», jetzt hier im Schlaf-Kontext.`
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
}
>
{renderSleepQuality()}
</ChartCard>
<ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer.">
{renderSleepDebt()}
</ChartCard>
<ChartCard title="📊 Recovery Score">{renderRecoveryScore()}</ChartCard> <SectionHeading
<ChartCard title="📊 HRV & Ruhepuls">{renderHrvRhr()}</ChartCard> title="Herz & Kreislauf"
<ChartCard title="📊 Schlaf: Dauer & Qualität">{renderSleepQuality()}</ChartCard> hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
<ChartCard title="📊 Schlafschuld">{renderSleepDebt()}</ChartCard> />
<ChartCard title="📊 Vitalwerte Überblick">{renderVitalSigns()}</ChartCard> <div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
<HeartAutonomicGuide />
{heartSectionInsights.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{heartSectionInsights.map((ins) => (
<SectionInsightCard key={ins.key} ins={ins} />
))}
</div>
) : null}
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
<SnapshotCards items={heartSnapshotItems} />
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
{vitalsData?.metadata?.vitals_measured_at ? (
<>
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
</>
) : null}
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
{vitalsData?.metadata?.blood_pressure_measured_at ? (
<>
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
</>
) : null}
</div>
) : null}
{vitalsData?.metadata?.disclaimer_de ? (
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
{vitalsData.metadata.disclaimer_de}
</div>
) : null}
</div>
<ChartCard
title="HRV & Ruhepuls — Zeitverlauf"
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
>
{renderHrvRhr()}
</ChartCard>
<SectionHeading
title="Weitere Vitalparameter (Verlauf)"
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
/>
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
</div>
</div> </div>
) )
} }