Fitness historie #95
454
backend/data_layer/recovery_chart_payloads.py
Normal file
454
backend/data_layer/recovery_chart_payloads.py
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
"""
|
||||
Chart.js-Payloads für Recovery (R1–R5) — gemeinsam mit routers/charts und recovery-dashboard-viz.
|
||||
|
||||
Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.recovery_metrics import (
|
||||
calculate_hrv_vs_baseline_pct,
|
||||
calculate_recovery_score_v2,
|
||||
calculate_rhr_vs_baseline_pct,
|
||||
calculate_sleep_debt_hours,
|
||||
get_sleep_duration_data,
|
||||
get_sleep_quality_data,
|
||||
)
|
||||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
||||
|
||||
|
||||
def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 90:
|
||||
days = 90
|
||||
current_score = calculate_recovery_score_v2(profile_id)
|
||||
|
||||
if current_score is None:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Recovery-Daten vorhanden",
|
||||
},
|
||||
}
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT date, resting_hr, hrv
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [datetime.now().strftime("%Y-%m-%d")],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Recovery Score",
|
||||
"data": [current_score],
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "low",
|
||||
"data_points": 1,
|
||||
"current_score": current_score,
|
||||
},
|
||||
}
|
||||
|
||||
labels = [row["date"].isoformat() for row in rows]
|
||||
values = [min(100, max(0, safe_float(row["hrv"]) if row["hrv"] else 50)) for row in rows]
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Recovery Score (proxy)",
|
||||
"data": values,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"current_score": current_score,
|
||||
"note": "Score based on HRV proxy; true recovery score calculation in development",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_hrv_rhr_baseline_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 90:
|
||||
days = 90
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT date, resting_hr, hrv
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Vitalwerte vorhanden",
|
||||
},
|
||||
}
|
||||
|
||||
labels = [row["date"].isoformat() for row in rows]
|
||||
hrv_values = [safe_float(row["hrv"]) if row["hrv"] else None for row in rows]
|
||||
rhr_values = [safe_float(row["resting_hr"]) if row["resting_hr"] else None for row in rows]
|
||||
|
||||
hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id)
|
||||
rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id)
|
||||
|
||||
hrv_filtered = [v for v in hrv_values if v is not None]
|
||||
rhr_filtered = [v for v in rhr_values if v is not None]
|
||||
|
||||
avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50
|
||||
avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "HRV (ms)",
|
||||
"data": hrv_values,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y1",
|
||||
"fill": False,
|
||||
},
|
||||
{
|
||||
"label": "RHR (bpm)",
|
||||
"data": rhr_values,
|
||||
"borderColor": "#3B82F6",
|
||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y2",
|
||||
"fill": False,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": labels, "datasets": datasets},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"avg_hrv": round(avg_hrv, 1),
|
||||
"avg_rhr": round(avg_rhr, 1),
|
||||
"hrv_vs_baseline_pct": hrv_baseline,
|
||||
"rhr_vs_baseline_pct": rhr_baseline,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 90:
|
||||
days = 90
|
||||
duration_data = get_sleep_duration_data(profile_id, days)
|
||||
quality_data = get_sleep_quality_data(profile_id, days)
|
||||
|
||||
if duration_data["confidence"] == "insufficient":
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten vorhanden",
|
||||
},
|
||||
}
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT date, total_sleep_min
|
||||
FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten",
|
||||
},
|
||||
}
|
||||
|
||||
labels = [row["date"].isoformat() for row in rows]
|
||||
duration_hours = [safe_float(row["total_sleep_min"]) / 60 if row["total_sleep_min"] else None for row in rows]
|
||||
|
||||
quality_scores = [(d / 8 * 100) if d else None for d in duration_hours]
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "Schlafdauer (h)",
|
||||
"data": duration_hours,
|
||||
"borderColor": "#3B82F6",
|
||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y1",
|
||||
"fill": True,
|
||||
},
|
||||
{
|
||||
"label": "Qualität (%)",
|
||||
"data": quality_scores,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y2",
|
||||
"fill": False,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": labels, "datasets": datasets},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": duration_data["confidence"],
|
||||
"data_points": len(rows),
|
||||
"avg_duration_hours": round(duration_data["avg_duration_hours"], 1),
|
||||
"sleep_quality_score": quality_data.get("sleep_quality_score", 0),
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 90:
|
||||
days = 90
|
||||
current_debt = calculate_sleep_debt_hours(profile_id)
|
||||
|
||||
if current_debt is None:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten für Schulden-Berechnung",
|
||||
},
|
||||
}
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT date, total_sleep_min
|
||||
FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten",
|
||||
},
|
||||
}
|
||||
|
||||
labels = [row["date"].isoformat() for row in rows]
|
||||
|
||||
target_hours = 8.0
|
||||
cumulative_debt = 0.0
|
||||
debt_values = []
|
||||
|
||||
for row in rows:
|
||||
actual_hours = safe_float(row["total_sleep_min"]) / 60 if row["total_sleep_min"] else 0
|
||||
daily_deficit = target_hours - actual_hours
|
||||
cumulative_debt += daily_deficit
|
||||
debt_values.append(cumulative_debt)
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Schlafschuld (Stunden)",
|
||||
"data": debt_values,
|
||||
"borderColor": "#EF4444",
|
||||
"backgroundColor": "rgba(239, 68, 68, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"current_debt_hours": round(float(current_debt), 1),
|
||||
"final_debt_hours": round(float(cumulative_debt), 1),
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 30:
|
||||
days = 30
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC
|
||||
LIMIT 1""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
vitals_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""SELECT systolic, diastolic
|
||||
FROM blood_pressure_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC, time DESC
|
||||
LIMIT 1""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
bp_row = cur.fetchone()
|
||||
|
||||
if not vitals_row and not bp_row:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine aktuellen Vitalwerte",
|
||||
},
|
||||
}
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
if vitals_row:
|
||||
if vitals_row["resting_hr"]:
|
||||
labels.append("Ruhepuls (bpm)")
|
||||
values.append(safe_float(vitals_row["resting_hr"]))
|
||||
if vitals_row["hrv"]:
|
||||
labels.append("HRV (ms)")
|
||||
values.append(safe_float(vitals_row["hrv"]))
|
||||
if vitals_row["vo2_max"]:
|
||||
labels.append("VO2 Max")
|
||||
values.append(safe_float(vitals_row["vo2_max"]))
|
||||
if vitals_row["spo2"]:
|
||||
labels.append("SpO2 (%)")
|
||||
values.append(safe_float(vitals_row["spo2"]))
|
||||
if vitals_row["respiratory_rate"]:
|
||||
labels.append("Atemfrequenz")
|
||||
values.append(safe_float(vitals_row["respiratory_rate"]))
|
||||
|
||||
if bp_row:
|
||||
if bp_row["systolic"]:
|
||||
labels.append("Blutdruck sys (mmHg)")
|
||||
values.append(safe_float(bp_row["systolic"]))
|
||||
if bp_row["diastolic"]:
|
||||
labels.append("Blutdruck dia (mmHg)")
|
||||
values.append(safe_float(bp_row["diastolic"]))
|
||||
|
||||
if not labels:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Vitalwerte verfügbar",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Wert",
|
||||
"data": values,
|
||||
"backgroundColor": "#1D9E75",
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "medium",
|
||||
"data_points": len(values),
|
||||
"note": "Latest measurements within last " + str(days) + " days",
|
||||
},
|
||||
}
|
||||
183
backend/data_layer/recovery_interpretation.py
Normal file
183
backend/data_layer/recovery_interpretation.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""
|
||||
KPIs und Kurz-Aussagen für Recovery-Dashboard (Layer 2b).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _verdict(status: str) -> str:
|
||||
if status == "good":
|
||||
return "Gut"
|
||||
if status == "warn":
|
||||
return "Hinweis"
|
||||
return "Achtung"
|
||||
|
||||
|
||||
def _recovery_score_status(score: Optional[int]) -> str:
|
||||
if score is None:
|
||||
return "warn"
|
||||
if score >= 70:
|
||||
return "good"
|
||||
if score >= 45:
|
||||
return "warn"
|
||||
return "bad"
|
||||
|
||||
|
||||
def _debt_status(hours: Optional[float]) -> str:
|
||||
if hours is None:
|
||||
return "warn"
|
||||
if hours <= 2:
|
||||
return "good"
|
||||
if hours <= 8:
|
||||
return "warn"
|
||||
return "bad"
|
||||
|
||||
|
||||
def build_recovery_dashboard_kpi_tiles(
|
||||
recovery_score: Optional[int],
|
||||
sleep_debt_hours: Optional[float],
|
||||
avg_sleep_hours: Optional[float],
|
||||
hrv_vs_baseline_pct: Optional[float],
|
||||
rhr_vs_baseline_pct: Optional[float],
|
||||
) -> List[Dict[str, Any]]:
|
||||
tiles: List[Dict[str, Any]] = []
|
||||
|
||||
rs = _recovery_score_status(recovery_score)
|
||||
tiles.append(
|
||||
{
|
||||
"key": "recovery_score",
|
||||
"category": "Recovery-Score",
|
||||
"icon": "💚",
|
||||
"value": str(recovery_score) if recovery_score is not None else "—",
|
||||
"sublabel": "Modell aus Schlaf + Vitaldaten",
|
||||
"status": rs,
|
||||
"verdict": _verdict(rs),
|
||||
"hoverTop": "Gesamt-Recovery-Score (0–100)",
|
||||
"hoverBody": "calculate_recovery_score_v2 — gleiche Quelle wie Platzhalter.",
|
||||
"keys": ["recovery_score"],
|
||||
}
|
||||
)
|
||||
|
||||
ds = _debt_status(sleep_debt_hours)
|
||||
tiles.append(
|
||||
{
|
||||
"key": "sleep_debt",
|
||||
"category": "Schlafschuld",
|
||||
"icon": "⏳",
|
||||
"value": f"{sleep_debt_hours:.1f} h".replace(".", ",")
|
||||
if sleep_debt_hours is not None
|
||||
else "—",
|
||||
"sublabel": "Kumuliert (Ziel 8 h/Nacht)",
|
||||
"status": ds,
|
||||
"verdict": _verdict(ds),
|
||||
"hoverTop": "Geschätzte Schlafschuld",
|
||||
"hoverBody": "calculate_sleep_debt_hours",
|
||||
"keys": ["sleep_debt_hours"],
|
||||
}
|
||||
)
|
||||
|
||||
tiles.append(
|
||||
{
|
||||
"key": "avg_sleep",
|
||||
"category": "Ø Schlafdauer",
|
||||
"icon": "🌙",
|
||||
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—",
|
||||
"sublabel": "Im gewählten Fenster",
|
||||
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
|
||||
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
|
||||
"hoverTop": "Durchschnittliche Schlafdauer",
|
||||
"hoverBody": "get_sleep_duration_data",
|
||||
"keys": ["sleep_duration_avg"],
|
||||
}
|
||||
)
|
||||
|
||||
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(
|
||||
{
|
||||
"key": "rhr_baseline",
|
||||
"category": "Ruhepuls vs. Basis",
|
||||
"icon": "❤️",
|
||||
"value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",")
|
||||
if rhr_vs_baseline_pct is not None
|
||||
else "—",
|
||||
"sublabel": "Niedriger oft günstiger",
|
||||
"status": "good",
|
||||
"verdict": "Gut",
|
||||
"hoverTop": "Abweichung Ruhepuls",
|
||||
"hoverBody": "calculate_rhr_vs_baseline_pct",
|
||||
"keys": ["rhr_vs_baseline"],
|
||||
}
|
||||
)
|
||||
|
||||
return tiles
|
||||
|
||||
|
||||
def build_recovery_progress_insights(
|
||||
recovery_score: Optional[int],
|
||||
sleep_debt_hours: Optional[float],
|
||||
hrv_vs_baseline_pct: Optional[float],
|
||||
) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
|
||||
if recovery_score is not None:
|
||||
tone = "good" if recovery_score >= 65 else "warn" if recovery_score >= 45 else "bad"
|
||||
out.append(
|
||||
{
|
||||
"key": "ins_rec",
|
||||
"tone": tone,
|
||||
"title": "Gesamterholung",
|
||||
"body": f"Der Recovery-Score liegt bei {recovery_score}/100. "
|
||||
"Er kombiniert Schlaf- und Vital-Signale — ideal für die Einordnung von Trainingstagen.",
|
||||
}
|
||||
)
|
||||
|
||||
if sleep_debt_hours is not None:
|
||||
tone = "good" if sleep_debt_hours <= 3 else "warn" if sleep_debt_hours <= 10 else "bad"
|
||||
out.append(
|
||||
{
|
||||
"key": "ins_debt",
|
||||
"tone": tone,
|
||||
"title": "Schlaf nachholen",
|
||||
"body": f"Geschätzte Schlafschuld: {sleep_debt_hours:.1f} h. "
|
||||
"Hohe Schulden erhöhen Verletzungs- und Ermüdungsrisiko — Priorität Schlafhygiene.",
|
||||
}
|
||||
)
|
||||
|
||||
if hrv_vs_baseline_pct is not None:
|
||||
tone = "good" if hrv_vs_baseline_pct >= 0 else "warn"
|
||||
out.append(
|
||||
{
|
||||
"key": "ins_hrv",
|
||||
"tone": tone,
|
||||
"title": "Autonomes System",
|
||||
"body": f"HRV liegt {hrv_vs_baseline_pct:+.1f} % relativ zur Basis. "
|
||||
"Positive Werte werden oft mit guter Regeneration assoziiert (individuell interpretieren).",
|
||||
}
|
||||
)
|
||||
|
||||
return out
|
||||
111
backend/data_layer/recovery_viz.py
Normal file
111
backend/data_layer/recovery_viz.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""
|
||||
Layer 2b: Recovery/Erholung — Bundle für Verlauf unter Fitness (Issue 53).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.recovery_chart_payloads import (
|
||||
build_hrv_rhr_baseline_chart_payload,
|
||||
build_recovery_score_chart_payload,
|
||||
build_sleep_debt_chart_payload,
|
||||
build_sleep_duration_quality_chart_payload,
|
||||
build_vital_signs_matrix_chart_payload,
|
||||
)
|
||||
from data_layer.recovery_interpretation import (
|
||||
build_recovery_dashboard_kpi_tiles,
|
||||
build_recovery_progress_insights,
|
||||
)
|
||||
from data_layer.recovery_metrics import (
|
||||
calculate_hrv_vs_baseline_pct,
|
||||
calculate_recovery_score_v2,
|
||||
calculate_rhr_vs_baseline_pct,
|
||||
calculate_sleep_debt_hours,
|
||||
get_sleep_duration_data,
|
||||
)
|
||||
|
||||
|
||||
def _has_recovery_sources(profile_id: str) -> bool:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT 1 FROM sleep_log WHERE profile_id=%s LIMIT 1", (profile_id,))
|
||||
if cur.fetchone():
|
||||
return True
|
||||
cur.execute("SELECT 1 FROM vitals_baseline WHERE profile_id=%s LIMIT 1", (profile_id,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Ein Request: KPIs, Insights, Charts R1–R5 (Chart.js-kompatibel).
|
||||
"""
|
||||
if not _has_recovery_sources(profile_id):
|
||||
return {
|
||||
"confidence": "insufficient",
|
||||
"has_recovery_data": False,
|
||||
"message": "Noch keine Schlaf- oder Vitaldaten",
|
||||
"kpi_tiles": [],
|
||||
"progress_insights": [],
|
||||
"charts": {},
|
||||
"meta": {"layer_1": "recovery_metrics", "layer_2b": "recovery_viz"},
|
||||
}
|
||||
|
||||
all_history = days >= 9999
|
||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
||||
chart_days = min(90, max(7, min(eff_days, 365)))
|
||||
vital_days = min(30, max(7, chart_days))
|
||||
|
||||
recovery_score_val = calculate_recovery_score_v2(profile_id)
|
||||
sleep_debt = calculate_sleep_debt_hours(profile_id)
|
||||
dur = get_sleep_duration_data(profile_id, chart_days)
|
||||
avg_sleep = None
|
||||
if dur.get("confidence") != "insufficient":
|
||||
avg_sleep = float(dur.get("avg_duration_hours") or 0) or None
|
||||
|
||||
hrv_dev = calculate_hrv_vs_baseline_pct(profile_id)
|
||||
rhr_dev = calculate_rhr_vs_baseline_pct(profile_id)
|
||||
|
||||
kpi_tiles = build_recovery_dashboard_kpi_tiles(
|
||||
recovery_score_val,
|
||||
float(sleep_debt) if sleep_debt is not None else None,
|
||||
avg_sleep,
|
||||
float(hrv_dev) if hrv_dev is not None else None,
|
||||
float(rhr_dev) if rhr_dev is not None else None,
|
||||
)
|
||||
|
||||
insights = build_recovery_progress_insights(
|
||||
recovery_score_val,
|
||||
float(sleep_debt) if sleep_debt is not None else None,
|
||||
float(hrv_dev) if hrv_dev is not None else None,
|
||||
)
|
||||
|
||||
charts = {
|
||||
"recovery_score": build_recovery_score_chart_payload(profile_id, chart_days),
|
||||
"hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days),
|
||||
"sleep_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days),
|
||||
"sleep_debt": build_sleep_debt_chart_payload(profile_id, chart_days),
|
||||
"vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days),
|
||||
}
|
||||
|
||||
conf = "medium"
|
||||
if recovery_score_val is None and sleep_debt is None:
|
||||
conf = "low"
|
||||
|
||||
return {
|
||||
"confidence": conf,
|
||||
"has_recovery_data": True,
|
||||
"days_requested": days,
|
||||
"effective_window_days": eff_days,
|
||||
"chart_days_used": chart_days,
|
||||
"vital_matrix_days_used": vital_days,
|
||||
"kpi_tiles": kpi_tiles,
|
||||
"progress_insights": insights,
|
||||
"charts": charts,
|
||||
"meta": {
|
||||
"layer_1": "recovery_metrics",
|
||||
"layer_2b": "recovery_viz",
|
||||
"issue": "53-layer-2b-recovery",
|
||||
},
|
||||
}
|
||||
|
|
@ -34,6 +34,14 @@ from data_layer.body_metrics import (
|
|||
from data_layer.body_viz import get_body_history_viz_bundle
|
||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
||||
from data_layer.recovery_chart_payloads import (
|
||||
build_recovery_score_chart_payload,
|
||||
build_hrv_rhr_baseline_chart_payload,
|
||||
build_sleep_duration_quality_chart_payload,
|
||||
build_sleep_debt_chart_payload,
|
||||
build_vital_signs_matrix_chart_payload,
|
||||
)
|
||||
from data_layer.nutrition_metrics import (
|
||||
get_nutrition_average_data,
|
||||
get_protein_targets_data,
|
||||
|
|
@ -310,6 +318,24 @@ def get_fitness_dashboard_viz(
|
|||
return serialize_dates(bundle)
|
||||
|
||||
|
||||
@router.get("/recovery-dashboard-viz")
|
||||
def get_recovery_dashboard_viz(
|
||||
days: int = Query(
|
||||
default=28,
|
||||
ge=7,
|
||||
le=9999,
|
||||
description="Analysefenster in Tagen (9999 = lange Historie)",
|
||||
),
|
||||
session: dict = Depends(require_auth),
|
||||
) -> Dict:
|
||||
"""
|
||||
Layer 2b: Recovery/Erholung — KPIs, Insights, Charts R1–R5 (recovery_metrics).
|
||||
"""
|
||||
profile_id = session["profile_id"]
|
||||
bundle = get_recovery_dashboard_viz_bundle(profile_id, days)
|
||||
return serialize_dates(bundle)
|
||||
|
||||
|
||||
@router.get("/circumferences")
|
||||
def get_circumferences_chart(
|
||||
max_age_days: int = Query(default=90, ge=7, le=365),
|
||||
|
|
@ -1368,106 +1394,9 @@ def get_recovery_score_chart(
|
|||
days: int = Query(default=28, ge=7, le=90),
|
||||
session: dict = Depends(require_auth)
|
||||
) -> Dict:
|
||||
"""
|
||||
Recovery score timeline (R1).
|
||||
|
||||
Shows daily recovery scores over time.
|
||||
|
||||
Args:
|
||||
days: Analysis window (7-90 days, default 28)
|
||||
session: Auth session (injected)
|
||||
|
||||
Returns:
|
||||
Chart.js line chart with recovery scores
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
# For PoC: Use current recovery score and create synthetic timeline
|
||||
# TODO: Store historical recovery scores for true timeline
|
||||
current_score = calculate_recovery_score_v2(profile_id)
|
||||
|
||||
if current_score is None:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Recovery-Daten vorhanden"
|
||||
}
|
||||
}
|
||||
|
||||
# Fetch vitals for timeline approximation
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, resting_hr, hrv_ms
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [datetime.now().strftime('%Y-%m-%d')],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Recovery Score",
|
||||
"data": [current_score],
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "low",
|
||||
"data_points": 1,
|
||||
"current_score": current_score
|
||||
}
|
||||
}
|
||||
|
||||
# Simple proxy: Use HRV as recovery indicator (higher HRV = better recovery)
|
||||
# This is a placeholder until we store actual recovery scores
|
||||
labels = [row['date'].isoformat() for row in rows]
|
||||
# Normalize HRV to 0-100 scale (assume typical range 20-100ms)
|
||||
values = [min(100, max(0, safe_float(row['hrv_ms']) if row['hrv_ms'] else 50)) for row in rows]
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Recovery Score (proxy)",
|
||||
"data": values,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": serialize_dates({
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"current_score": current_score,
|
||||
"note": "Score based on HRV proxy; true recovery score calculation in development"
|
||||
})
|
||||
}
|
||||
"""Recovery score timeline (R1). Delegiert an recovery_chart_payloads."""
|
||||
profile_id = session["profile_id"]
|
||||
return build_recovery_score_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/hrv-rhr-baseline")
|
||||
|
|
@ -1475,101 +1404,9 @@ def get_hrv_rhr_baseline_chart(
|
|||
days: int = Query(default=28, ge=7, le=90),
|
||||
session: dict = Depends(require_auth)
|
||||
) -> Dict:
|
||||
"""
|
||||
HRV/RHR vs baseline (R2).
|
||||
|
||||
Shows HRV and RHR trends vs. baseline values.
|
||||
|
||||
Args:
|
||||
days: Analysis window (7-90 days, default 28)
|
||||
session: Auth session (injected)
|
||||
|
||||
Returns:
|
||||
Chart.js multi-line chart with HRV and RHR
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, resting_hr, hrv_ms
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Vitalwerte vorhanden"
|
||||
}
|
||||
}
|
||||
|
||||
labels = [row['date'].isoformat() for row in rows]
|
||||
hrv_values = [safe_float(row['hrv_ms']) if row['hrv_ms'] else None for row in rows]
|
||||
rhr_values = [safe_float(row['resting_hr']) if row['resting_hr'] else None for row in rows]
|
||||
|
||||
# Calculate baselines (28d median)
|
||||
hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id) # This returns % deviation
|
||||
rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id) # This returns % deviation
|
||||
|
||||
# For chart, we need actual baseline values (approximation)
|
||||
hrv_filtered = [v for v in hrv_values if v is not None]
|
||||
rhr_filtered = [v for v in rhr_values if v is not None]
|
||||
|
||||
avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50
|
||||
avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "HRV (ms)",
|
||||
"data": hrv_values,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y1",
|
||||
"fill": False
|
||||
},
|
||||
{
|
||||
"label": "RHR (bpm)",
|
||||
"data": rhr_values,
|
||||
"borderColor": "#3B82F6",
|
||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y2",
|
||||
"fill": False
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": datasets
|
||||
},
|
||||
"metadata": serialize_dates({
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"avg_hrv": round(avg_hrv, 1),
|
||||
"avg_rhr": round(avg_rhr, 1),
|
||||
"hrv_vs_baseline_pct": hrv_baseline,
|
||||
"rhr_vs_baseline_pct": rhr_baseline
|
||||
})
|
||||
}
|
||||
"""HRV/RHR vs baseline (R2)."""
|
||||
profile_id = session["profile_id"]
|
||||
return build_hrv_rhr_baseline_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/sleep-duration-quality")
|
||||
|
|
@ -1577,107 +1414,9 @@ def get_sleep_duration_quality_chart(
|
|||
days: int = Query(default=28, ge=7, le=90),
|
||||
session: dict = Depends(require_auth)
|
||||
) -> Dict:
|
||||
"""
|
||||
Sleep duration + quality (R3).
|
||||
|
||||
Shows sleep duration and quality score over time.
|
||||
|
||||
Args:
|
||||
days: Analysis window (7-90 days, default 28)
|
||||
session: Auth session (injected)
|
||||
|
||||
Returns:
|
||||
Chart.js multi-line chart with sleep metrics
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
duration_data = get_sleep_duration_data(profile_id, days)
|
||||
quality_data = get_sleep_quality_data(profile_id, days)
|
||||
|
||||
if duration_data['confidence'] == 'insufficient':
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten vorhanden"
|
||||
}
|
||||
}
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, total_sleep_min
|
||||
FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten"
|
||||
}
|
||||
}
|
||||
|
||||
labels = [row['date'].isoformat() for row in rows]
|
||||
duration_hours = [safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else None for row in rows]
|
||||
|
||||
# Quality score (simple proxy: % of 8 hours)
|
||||
quality_scores = [(d / 8 * 100) if d else None for d in duration_hours]
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "Schlafdauer (h)",
|
||||
"data": duration_hours,
|
||||
"borderColor": "#3B82F6",
|
||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y1",
|
||||
"fill": True
|
||||
},
|
||||
{
|
||||
"label": "Qualität (%)",
|
||||
"data": quality_scores,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"yAxisID": "y2",
|
||||
"fill": False
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": datasets
|
||||
},
|
||||
"metadata": serialize_dates({
|
||||
"confidence": duration_data['confidence'],
|
||||
"data_points": len(rows),
|
||||
"avg_duration_hours": round(duration_data['avg_duration_hours'], 1),
|
||||
"sleep_quality_score": quality_data.get('sleep_quality_score', 0)
|
||||
})
|
||||
}
|
||||
"""Sleep duration + quality (R3)."""
|
||||
profile_id = session["profile_id"]
|
||||
return build_sleep_duration_quality_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/sleep-debt")
|
||||
|
|
@ -1685,100 +1424,9 @@ def get_sleep_debt_chart(
|
|||
days: int = Query(default=28, ge=7, le=90),
|
||||
session: dict = Depends(require_auth)
|
||||
) -> Dict:
|
||||
"""
|
||||
Sleep debt accumulation (R4).
|
||||
|
||||
Shows cumulative sleep debt over time.
|
||||
|
||||
Args:
|
||||
days: Analysis window (7-90 days, default 28)
|
||||
session: Auth session (injected)
|
||||
|
||||
Returns:
|
||||
Chart.js line chart with sleep debt
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
current_debt = calculate_sleep_debt_hours(profile_id)
|
||||
|
||||
if current_debt is None:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten für Schulden-Berechnung"
|
||||
}
|
||||
}
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, total_sleep_min
|
||||
FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Schlafdaten"
|
||||
}
|
||||
}
|
||||
|
||||
labels = [row['date'].isoformat() for row in rows]
|
||||
|
||||
# Calculate cumulative debt (target 8h/night)
|
||||
target_hours = 8.0
|
||||
cumulative_debt = 0
|
||||
debt_values = []
|
||||
|
||||
for row in rows:
|
||||
actual_hours = safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else 0
|
||||
daily_deficit = target_hours - actual_hours
|
||||
cumulative_debt += daily_deficit
|
||||
debt_values.append(cumulative_debt)
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Schlafschuld (Stunden)",
|
||||
"data": debt_values,
|
||||
"borderColor": "#EF4444",
|
||||
"backgroundColor": "rgba(239, 68, 68, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": serialize_dates({
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"current_debt_hours": round(current_debt, 1),
|
||||
"final_debt_hours": round(cumulative_debt, 1)
|
||||
})
|
||||
}
|
||||
"""Sleep debt (R4)."""
|
||||
profile_id = session["profile_id"]
|
||||
return build_sleep_debt_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/vital-signs-matrix")
|
||||
|
|
@ -1786,123 +1434,9 @@ def get_vital_signs_matrix_chart(
|
|||
days: int = Query(default=7, ge=7, le=30),
|
||||
session: dict = Depends(require_auth)
|
||||
) -> Dict:
|
||||
"""
|
||||
Vital signs matrix (R5).
|
||||
|
||||
Shows latest vital signs as horizontal bar chart.
|
||||
|
||||
Args:
|
||||
days: Max age of measurements (7-30 days, default 7)
|
||||
session: Auth session (injected)
|
||||
|
||||
Returns:
|
||||
Chart.js horizontal bar chart with vital signs
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# Get latest vitals
|
||||
cur.execute(
|
||||
"""SELECT resting_hr, hrv_ms, vo2_max, spo2, respiratory_rate
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC
|
||||
LIMIT 1""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
vitals_row = cur.fetchone()
|
||||
|
||||
# Get latest blood pressure
|
||||
cur.execute(
|
||||
"""SELECT systolic, diastolic
|
||||
FROM blood_pressure_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC, time DESC
|
||||
LIMIT 1""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
bp_row = cur.fetchone()
|
||||
|
||||
if not vitals_row and not bp_row:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine aktuellen Vitalwerte"
|
||||
}
|
||||
}
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
if vitals_row:
|
||||
if vitals_row['resting_hr']:
|
||||
labels.append("Ruhepuls (bpm)")
|
||||
values.append(safe_float(vitals_row['resting_hr']))
|
||||
if vitals_row['hrv_ms']:
|
||||
labels.append("HRV (ms)")
|
||||
values.append(safe_float(vitals_row['hrv_ms']))
|
||||
if vitals_row['vo2_max']:
|
||||
labels.append("VO2 Max")
|
||||
values.append(safe_float(vitals_row['vo2_max']))
|
||||
if vitals_row['spo2']:
|
||||
labels.append("SpO2 (%)")
|
||||
values.append(safe_float(vitals_row['spo2']))
|
||||
if vitals_row['respiratory_rate']:
|
||||
labels.append("Atemfrequenz")
|
||||
values.append(safe_float(vitals_row['respiratory_rate']))
|
||||
|
||||
if bp_row:
|
||||
if bp_row['systolic']:
|
||||
labels.append("Blutdruck sys (mmHg)")
|
||||
values.append(safe_float(bp_row['systolic']))
|
||||
if bp_row['diastolic']:
|
||||
labels.append("Blutdruck dia (mmHg)")
|
||||
values.append(safe_float(bp_row['diastolic']))
|
||||
|
||||
if not labels:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Vitalwerte verfügbar"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Wert",
|
||||
"data": values,
|
||||
"backgroundColor": "#1D9E75",
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "medium",
|
||||
"data_points": len(values),
|
||||
"note": "Latest measurements within last " + str(days) + " days"
|
||||
}
|
||||
}
|
||||
"""Vital signs matrix (R5)."""
|
||||
profile_id = session["profile_id"]
|
||||
return build_vital_signs_matrix_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
# ── Correlation Charts ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,320 +1,8 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid
|
||||
} from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const fmtDate = d => dayjs(d).format('DD.MM')
|
||||
|
||||
function ChartCard({ title, loading, error, children }) {
|
||||
return (
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||||
{title}
|
||||
</div>
|
||||
{loading && (
|
||||
<div style={{display:'flex',justifyContent:'center',padding:40}}>
|
||||
<div className="spinner" style={{width:32,height:32}}/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import RecoveryDashboardOverview from './RecoveryDashboardOverview'
|
||||
|
||||
/**
|
||||
* Recovery Charts Component (R1-R5)
|
||||
*
|
||||
* Displays 5 recovery chart endpoints:
|
||||
* - Recovery Score Timeline (R1)
|
||||
* - HRV/RHR vs Baseline (R2)
|
||||
* - Sleep Duration + Quality (R3)
|
||||
* - Sleep Debt (R4)
|
||||
* - Vital Signs Matrix (R5)
|
||||
* @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days → period).
|
||||
*/
|
||||
export default function RecoveryCharts({ days = 28 }) {
|
||||
const [recoveryData, setRecoveryData] = useState(null)
|
||||
const [hrvRhrData, setHrvRhrData] = useState(null)
|
||||
const [sleepData, setSleepData] = useState(null)
|
||||
const [debtData, setDebtData] = useState(null)
|
||||
const [vitalsData, setVitalsData] = useState(null)
|
||||
|
||||
const [loading, setLoading] = useState({})
|
||||
const [errors, setErrors] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
loadCharts()
|
||||
}, [days])
|
||||
|
||||
const loadCharts = async () => {
|
||||
// Load all 5 charts in parallel
|
||||
await Promise.all([
|
||||
loadRecoveryScore(),
|
||||
loadHrvRhr(),
|
||||
loadSleepQuality(),
|
||||
loadSleepDebt(),
|
||||
loadVitalSigns()
|
||||
])
|
||||
}
|
||||
|
||||
const loadRecoveryScore = async () => {
|
||||
setLoading(l => ({...l, recovery: true}))
|
||||
setErrors(e => ({...e, recovery: null}))
|
||||
try {
|
||||
const data = await api.getRecoveryScoreChart(days)
|
||||
setRecoveryData(data)
|
||||
} catch (err) {
|
||||
setErrors(e => ({...e, recovery: err.message}))
|
||||
} finally {
|
||||
setLoading(l => ({...l, recovery: false}))
|
||||
}
|
||||
}
|
||||
|
||||
const loadHrvRhr = async () => {
|
||||
setLoading(l => ({...l, hrvRhr: true}))
|
||||
setErrors(e => ({...e, hrvRhr: null}))
|
||||
try {
|
||||
const data = await api.getHrvRhrBaselineChart(days)
|
||||
setHrvRhrData(data)
|
||||
} catch (err) {
|
||||
setErrors(e => ({...e, hrvRhr: err.message}))
|
||||
} finally {
|
||||
setLoading(l => ({...l, hrvRhr: false}))
|
||||
}
|
||||
}
|
||||
|
||||
const loadSleepQuality = async () => {
|
||||
setLoading(l => ({...l, sleep: true}))
|
||||
setErrors(e => ({...e, sleep: null}))
|
||||
try {
|
||||
const data = await api.getSleepDurationQualityChart(days)
|
||||
setSleepData(data)
|
||||
} catch (err) {
|
||||
setErrors(e => ({...e, sleep: err.message}))
|
||||
} finally {
|
||||
setLoading(l => ({...l, sleep: false}))
|
||||
}
|
||||
}
|
||||
|
||||
const loadSleepDebt = async () => {
|
||||
setLoading(l => ({...l, debt: true}))
|
||||
setErrors(e => ({...e, debt: null}))
|
||||
try {
|
||||
const data = await api.getSleepDebtChart(days)
|
||||
setDebtData(data)
|
||||
} catch (err) {
|
||||
setErrors(e => ({...e, debt: err.message}))
|
||||
} finally {
|
||||
setLoading(l => ({...l, debt: false}))
|
||||
}
|
||||
}
|
||||
|
||||
const loadVitalSigns = async () => {
|
||||
setLoading(l => ({...l, vitals: true}))
|
||||
setErrors(e => ({...e, vitals: null}))
|
||||
try {
|
||||
const data = await api.getVitalSignsMatrixChart(7) // Last 7 days
|
||||
setVitalsData(data)
|
||||
} catch (err) {
|
||||
setErrors(e => ({...e, vitals: err.message}))
|
||||
} finally {
|
||||
setLoading(l => ({...l, vitals: false}))
|
||||
}
|
||||
}
|
||||
|
||||
// R1: Recovery Score Timeline
|
||||
const renderRecoveryScore = () => {
|
||||
if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Recovery-Daten vorhanden
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = recoveryData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
score: recoveryData.data.datasets[0]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
||||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={[0,100]}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Line type="monotone" dataKey="score" stroke="#1D9E75" strokeWidth={2} name="Recovery Score" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R2: HRV/RHR vs Baseline
|
||||
const renderHrvRhr = () => {
|
||||
if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Vitalwerte vorhanden
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = hrvRhrData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
hrv: hrvRhrData.data.datasets[0]?.data[i],
|
||||
rhr: hrvRhrData.data.datasets[1]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
||||
<YAxis yAxisId="left" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<YAxis yAxisId="right" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{r:2}}/>
|
||||
<Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R3: Sleep Duration + Quality
|
||||
const renderSleepQuality = () => {
|
||||
if (!sleepData || sleepData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Schlafdaten vorhanden
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = sleepData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
duration: sleepData.data.datasets[0]?.data[i],
|
||||
quality: sleepData.data.datasets[1]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
||||
<YAxis yAxisId="left" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<YAxis yAxisId="right" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={[0,100]}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{r:2}}/>
|
||||
<Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R4: Sleep Debt
|
||||
const renderSleepDebt = () => {
|
||||
if (!debtData || debtData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine Schlafdaten für Schulden-Berechnung
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = debtData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
debt: debtData.data.datasets[0]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
||||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Line type="monotone" dataKey="debt" stroke="#EF4444" strokeWidth={2} name="Schlafschuld (h)" dot={{r:2}}/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Aktuelle Schuld: {debtData.metadata.current_debt_hours.toFixed(1)}h
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// R5: Vital Signs Matrix (Bar)
|
||||
const renderVitalSigns = () => {
|
||||
if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') {
|
||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||
Keine aktuellen Vitalwerte
|
||||
</div>
|
||||
}
|
||||
|
||||
const chartData = vitalsData.data.labels.map((label, i) => ({
|
||||
name: label,
|
||||
value: vitalsData.data.datasets[0]?.data[i]
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:20}} layout="horizontal">
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis type="number" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<YAxis type="category" dataKey="name" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} width={120}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||
<Bar dataKey="value" fill="#1D9E75" name="Wert"/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||
Letzte {vitalsData.metadata.data_points} Messwerte (7 Tage)
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartCard title="📊 Recovery Score" loading={loading.recovery} error={errors.recovery}>
|
||||
{renderRecoveryScore()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 HRV & Ruhepuls" loading={loading.hrvRhr} error={errors.hrvRhr}>
|
||||
{renderHrvRhr()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Schlaf: Dauer & Qualität" loading={loading.sleep} error={errors.sleep}>
|
||||
{renderSleepQuality()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Schlafschuld" loading={loading.debt} error={errors.debt}>
|
||||
{renderSleepDebt()}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="📊 Vitalwerte Überblick" loading={loading.vitals} error={errors.vitals}>
|
||||
{renderVitalSigns()}
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
return <RecoveryDashboardOverview period={days} hidePeriodSelector />
|
||||
}
|
||||
|
|
|
|||
402
frontend/src/components/RecoveryDashboardOverview.jsx
Normal file
402
frontend/src/components/RecoveryDashboardOverview.jsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
import KpiTilesOverview from './KpiTilesOverview'
|
||||
import { getStatusColor } from '../utils/interpret'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const fmtDate = (d) => dayjs(d).format('DD.MM.')
|
||||
|
||||
function ChartCard({ title, loading, error, children }) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>{title}</div>
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{error}</div>
|
||||
)}
|
||||
{!loading && !error && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics).
|
||||
*/
|
||||
export default function RecoveryDashboardOverview({
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
hidePeriodSelector = false,
|
||||
}) {
|
||||
const nav = useNavigate()
|
||||
const [internalPeriod, setInternalPeriod] = useState(28)
|
||||
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
|
||||
const period = controlled ? periodProp : internalPeriod
|
||||
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
|
||||
|
||||
const [viz, setViz] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
api
|
||||
.getRecoveryDashboardViz(period)
|
||||
.then((v) => {
|
||||
if (!cancelled) setViz(v)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [period])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Erholung & Vitalwerte</div>
|
||||
<div className="spinner" style={{ margin: 24 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Erholung & Vitalwerte</div>
|
||||
<div style={{ color: 'var(--danger)' }}>{err}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viz?.has_recovery_data) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Erholung & Vitalwerte</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
||||
{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst
|
||||
oder importierst, erscheinen Auswertungen hier.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" onClick={() => nav('/vitals')}>
|
||||
Zu Vitalwerten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const recoveryData = viz.charts?.recovery_score
|
||||
const hrvRhrData = viz.charts?.hrv_rhr
|
||||
const sleepData = viz.charts?.sleep_duration_quality
|
||||
const debtData = viz.charts?.sleep_debt
|
||||
const vitalsData = viz.charts?.vital_signs_matrix
|
||||
|
||||
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
||||
...t,
|
||||
sublabel:
|
||||
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel,
|
||||
}))
|
||||
const insights = viz.progress_insights || []
|
||||
const eff = viz.effective_window_days
|
||||
const cDays = viz.chart_days_used
|
||||
const vDays = viz.vital_matrix_days_used
|
||||
|
||||
const showPeriodDropdown = !hidePeriodSelector && !controlled
|
||||
|
||||
const renderRecoveryScore = () => {
|
||||
if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine Recovery-Daten im Fenster
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chartData = recoveryData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
score: recoveryData.data.datasets[0]?.data[i],
|
||||
}))
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||
tickLine={false}
|
||||
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Line type="monotone" dataKey="score" stroke="#1D9E75" strokeWidth={2} name="Recovery Score" dot={{ r: 2 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHrvRhr = () => {
|
||||
if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine Vitalwerte im Fenster
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chartData = hrvRhrData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
hrv: hrvRhrData.data.datasets[0]?.data[i],
|
||||
rhr: hrvRhrData.data.datasets[1]?.data[i],
|
||||
}))
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||
tickLine={false}
|
||||
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
|
||||
/>
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{ r: 2 }} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{ r: 2 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSleepQuality = () => {
|
||||
if (!sleepData || sleepData.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine Schlafdaten im Fenster
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chartData = sleepData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
duration: sleepData.data.datasets[0]?.data[i],
|
||||
quality: sleepData.data.datasets[1]?.data[i],
|
||||
}))
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||
tickLine={false}
|
||||
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
|
||||
/>
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{ r: 2 }} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{ r: 2 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSleepDebt = () => {
|
||||
if (!debtData || debtData.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine Schlafdaten für Schulden-Berechnung
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chartData = debtData.data.labels.map((label, i) => ({
|
||||
date: fmtDate(label),
|
||||
debt: debtData.data.datasets[0]?.data[i],
|
||||
}))
|
||||
const curDebt = debtData.metadata?.current_debt_hours
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||
tickLine={false}
|
||||
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Line type="monotone" dataKey="debt" stroke="#EF4444" strokeWidth={2} name="Schlafschuld (h)" dot={{ r: 2 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVitalSigns = () => {
|
||||
if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine aktuellen Vitalwerte
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chartData = vitalsData.data.labels.map((label, i) => ({
|
||||
name: label,
|
||||
value: vitalsData.data.datasets[0]?.data[i],
|
||||
}))
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 20 }} layout="horizontal">
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="#1D9E75" name="Wert" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
Letzte {vitalsData.metadata.data_points} Messwerte ({vDays} Tage)
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
||||
<span>Erholung & Vitalwerte</span>
|
||||
{showPeriodDropdown ? (
|
||||
<label
|
||||
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
|
||||
>
|
||||
Zeitraum
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(Number(e.target.value))}
|
||||
>
|
||||
<option value={7}>7 Tage</option>
|
||||
<option value={28}>28 Tage</option>
|
||||
<option value={90}>90 Tage</option>
|
||||
<option value={9999}>Gesamt</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. <strong>{eff}</strong> Tage · Charts{' '}
|
||||
<strong>{cDays}</strong> Tage · Vital-Matrix <strong>{vDays}</strong> Tage.
|
||||
</p>
|
||||
|
||||
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
|
||||
|
||||
{insights.length > 0 ? (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{insights.map((ins) => (
|
||||
<div
|
||||
key={ins.key}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `4px solid ${getStatusColor(['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn')}`,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>Diagramme</div>
|
||||
|
||||
<ChartCard title="📊 Recovery Score">{renderRecoveryScore()}</ChartCard>
|
||||
<ChartCard title="📊 HRV & Ruhepuls">{renderHrvRhr()}</ChartCard>
|
||||
<ChartCard title="📊 Schlaf: Dauer & Qualität">{renderSleepQuality()}</ChartCard>
|
||||
<ChartCard title="📊 Schlafschuld">{renderSleepDebt()}</ChartCard>
|
||||
<ChartCard title="📊 Vitalwerte Überblick">{renderVitalSigns()}</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import RecoveryCharts from '../RecoveryCharts'
|
||||
import RecoveryDashboardOverview from '../RecoveryDashboardOverview'
|
||||
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
||||
|
||||
/**
|
||||
* Erholung R1–R5 (wie Verlauf Erholung).
|
||||
* Erholung Layer 2b (ein Bundle-Request). Link zum Verlauf unter Fitness.
|
||||
* @param {{ refreshTick?: number, chartDays?: number }} props
|
||||
*/
|
||||
export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }) {
|
||||
|
|
@ -11,22 +11,22 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }
|
|||
const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 28
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Erholung — Charts</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Erholung — Übersicht</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Schlaf, Recovery, Vitalwerte · {days} Tage</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||
onClick={() => nav('/history', { state: { tab: 'recovery' } })}
|
||||
onClick={() => nav('/history', { state: { tab: 'activity' } })}
|
||||
>
|
||||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<RecoveryCharts key={`${refreshTick}-${days}`} days={days} />
|
||||
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} period={days} hidePeriodSelector />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '..
|
|||
import Markdown from '../utils/Markdown'
|
||||
import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
|
||||
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
||||
import RecoveryCharts from '../components/RecoveryCharts'
|
||||
import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview'
|
||||
import KpiTilesOverview from '../components/KpiTilesOverview'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
|
|
@ -1108,11 +1108,15 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={actList[0]?.date}/>
|
||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Auswertung ausschließlich aus dem Fitness-Bundle (Data-Layer / Issue 53). Zeitraum-Buttons steuern dasselbe
|
||||
Fenster wie die API.
|
||||
Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.
|
||||
</p>
|
||||
<FitnessDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 20 }}>
|
||||
Erholung (Schlaf, HRV, Vitalwerte)
|
||||
</div>
|
||||
<RecoveryDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
|
||||
|
||||
{hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
|
|
@ -1134,9 +1138,12 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
</div>
|
||||
)}
|
||||
|
||||
{hasList ? (
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||
) : null}
|
||||
<InsightBox
|
||||
insights={insights}
|
||||
slugs={filterActiveSlugs(['aktivitaet', 'gesundheit'])}
|
||||
onRequest={onRequest}
|
||||
loading={loadingSlug}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1432,32 +1439,10 @@ function PhotoGrid() {
|
|||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
// ── Recovery Section ──────────────────────────────────────────────────────────
|
||||
function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
const [period, setPeriod] = useState(28)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="😴 Erholung & Vitalwerte" to="/vitals" toLabel="Daten"/>
|
||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||
|
||||
<div style={{marginBottom:12,fontSize:13,color:'var(--text2)',lineHeight:1.6}}>
|
||||
Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick.
|
||||
</div>
|
||||
|
||||
{/* Recovery Charts (Phase 0c) */}
|
||||
<RecoveryCharts days={period === 9999 ? 90 : period} />
|
||||
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesundheit'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id:'body', label:'⚖️ Körper' },
|
||||
{ id:'nutrition', label:'🍽️ Ernährung' },
|
||||
{ id:'activity', label:'🏋️ Fitness' },
|
||||
{ id:'recovery', label:'😴 Erholung' },
|
||||
{ id:'correlation', label:'🔗 Korrelation' },
|
||||
{ id:'photos', label:'📷 Fotos' },
|
||||
]
|
||||
|
|
@ -1497,6 +1482,10 @@ export default function History() {
|
|||
|
||||
useEffect(() => {
|
||||
const t = location.state?.tab
|
||||
if (t === 'recovery') {
|
||||
setTab('activity')
|
||||
return
|
||||
}
|
||||
if (t && TABS.some(x => x.id === t)) setTab(t)
|
||||
}, [location.state?.tab])
|
||||
|
||||
|
|
@ -1544,7 +1533,6 @@ export default function History() {
|
|||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='recovery' && <RecoverySection {...sp}/>}
|
||||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||||
{tab==='photos' && <PhotoGrid/>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -641,6 +641,8 @@ export const api = {
|
|||
getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`),
|
||||
/** Layer 2b: Fitness-Übersicht — KPI + Volumen/Typ-Charts (activity_metrics) */
|
||||
getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`),
|
||||
/** Layer 2b: Erholung — KPI, Insights, Charts R1–R5 (recovery_metrics) */
|
||||
getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`),
|
||||
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
|
||||
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
|
||||
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user