Merge pull request 'Neue Aufbereitung Fitness Verlauf' (#97) from develop into main
Reviewed-on: #97
This commit is contained in:
commit
a1b458d228
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,6 +80,7 @@ def build_recovery_dashboard_kpi_tiles(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if include_avg_sleep_kpi:
|
||||||
tiles.append(
|
tiles.append(
|
||||||
{
|
{
|
||||||
"key": "avg_sleep",
|
"key": "avg_sleep",
|
||||||
|
|
@ -93,6 +96,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 = (
|
h_s = (
|
||||||
"good"
|
"good"
|
||||||
if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0
|
if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
400
backend/data_layer/vitals_fitness_insights.py
Normal file
400
backend/data_layer/vitals_fitness_insights.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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,131 +489,90 @@ 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 (
|
return (
|
||||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
<div style={{ width: '100%', minWidth: 0 }}>
|
||||||
Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten).
|
{vo2Insights.length > 0 ? (
|
||||||
</div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||||
)
|
{vo2Insights.map((ins) => (
|
||||||
}
|
<SectionInsightCard key={ins.key} ins={ins} />
|
||||||
|
))}
|
||||||
const vitDate = meta.vitals_measured_at
|
|
||||||
const bpDate = meta.blood_pressure_measured_at
|
|
||||||
const disclaimer = meta.disclaimer_de
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{items.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
|
|
||||||
{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>
|
</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>
|
</div>
|
||||||
|
{keys.map((k) => {
|
||||||
|
const m = series[k]
|
||||||
|
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}
|
) : null}
|
||||||
|
|
||||||
{chartRows.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>
|
|
||||||
Relative Einordnung (0–100, nur Übersicht — keine körperliche Messgröße)
|
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={Math.max(200, chartRows.length * 36)}>
|
<VitalZoneHint item={zoneItem} />
|
||||||
<BarChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 8 }} layout="horizontal">
|
{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" />
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
|
<XAxis dataKey="d" tick={{ fontSize: 9, fill: 'var(--text3)' }} interval="preserveStartEnd" />
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={100} />
|
<YAxis
|
||||||
|
domain={[mn - pad, mx + pad]}
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickFormatter={formatAxisTick}
|
||||||
|
tickCount={6}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
|
|
@ -447,36 +580,38 @@ export default function RecoveryDashboardOverview({
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
}}
|
}}
|
||||||
formatter={(v) => [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']}
|
formatter={(value) => (value != null ? formatAxisTick(value) : '')}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" name="Einordnung" radius={[0, 3, 3, 0]}>
|
{hasMa ? <Legend wrapperStyle={{ fontSize: 10, paddingTop: 4 }} /> : null}
|
||||||
{chartRows.map((row, i) => (
|
<Line
|
||||||
<Cell key={`c-${i}`} fill={row.fill} />
|
type="monotone"
|
||||||
))}
|
dataKey="value"
|
||||||
</Bar>
|
stroke={m.color || '#1D9E75'}
|
||||||
</BarChart>
|
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>
|
</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 ? (
|
)}
|
||||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
|
</div>
|
||||||
) : null}
|
)
|
||||||
</>
|
})}
|
||||||
|
</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 style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{insights.map((ins) => {
|
||||||
|
const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn'
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={ins.key}
|
key={ins.key}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderLeft: `4px solid ${getStatusColor(['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn')}`,
|
borderLeft: `4px solid ${getStatusColor(t)}`,
|
||||||
background: 'var(--surface2)',
|
background: getStatusBg(t),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{ins.title}</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 style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
|
||||||
</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="0–100, 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user