Fitness historie #95
|
|
@ -330,24 +330,30 @@ def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
|
||||||
return int(row['session_count']) if row else None
|
return int(row['session_count']) if row else None
|
||||||
|
|
||||||
|
|
||||||
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
|
def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]:
|
||||||
"""Calculate percentage of quality sessions (good or better) last 28 days"""
|
"""Anteil qualitativ guter Sessions (quality_label) im Zeitfenster ``days``."""
|
||||||
|
if days < 1:
|
||||||
|
days = 28
|
||||||
|
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
||||||
FROM activity_log
|
FROM activity_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
AND date >= %s
|
||||||
""", (profile_id,))
|
""",
|
||||||
|
(profile_id, cutoff),
|
||||||
|
)
|
||||||
|
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or row['total'] == 0:
|
if not row or row["total"] == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
pct = (row['quality_count'] / row['total']) * 100
|
pct = (row["quality_count"] / row["total"]) * 100
|
||||||
return int(pct)
|
return int(pct)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -495,11 +501,12 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
||||||
# A5: Load Monitoring (Proxy-based)
|
# A5: Load Monitoring (Proxy-based)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Calculate proxy internal load (last 7 days)
|
Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage).
|
||||||
Formula: duration × intensity_factor × quality_factor
|
|
||||||
"""
|
"""
|
||||||
|
if days < 1:
|
||||||
|
days = 7
|
||||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
||||||
quality_factors = {
|
quality_factors = {
|
||||||
'excellent': 1.15,
|
'excellent': 1.15,
|
||||||
|
|
@ -512,12 +519,15 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
|
"""
|
||||||
SELECT duration_min, hr_avg, rpe
|
SELECT duration_min, hr_avg, rpe
|
||||||
FROM activity_log
|
FROM activity_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day')
|
||||||
""", (profile_id,))
|
""",
|
||||||
|
(profile_id, days),
|
||||||
|
)
|
||||||
|
|
||||||
activities = cur.fetchall()
|
activities = cur.fetchall()
|
||||||
|
|
||||||
|
|
@ -554,7 +564,12 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
||||||
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
||||||
total_load += load
|
total_load += load
|
||||||
|
|
||||||
return int(total_load)
|
return float(total_load)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[float]:
|
||||||
|
"""Letzte 7 Tage — Kompatibilität mit Platzhaltern / älteren Aufrufern."""
|
||||||
|
return calculate_proxy_internal_load_window(profile_id, 7)
|
||||||
|
|
||||||
|
|
||||||
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
||||||
|
|
@ -1222,3 +1237,301 @@ def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
|
||||||
"parameters": rows,
|
"parameters": rows,
|
||||||
"meta": {"count": len(rows), "scope": "global_active_catalog"},
|
"meta": {"count": len(rows), "scope": "global_active_catalog"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Chart payloads (Phase 0c / Layer 1) — gemeinsam mit charts-Router und Layer-2b-Bundles
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_training_volume_chart_payload(profile_id: str, weeks: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Wöchentliches Trainingsvolumen (Minuten) — gleiche Logik wie GET /api/charts/training-volume.
|
||||||
|
"""
|
||||||
|
if weeks < 4:
|
||||||
|
weeks = 4
|
||||||
|
if weeks > 52:
|
||||||
|
weeks = 52
|
||||||
|
|
||||||
|
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT
|
||||||
|
DATE_TRUNC('week', date) as week_start,
|
||||||
|
SUM(duration_min) as total_minutes,
|
||||||
|
COUNT(*) as session_count
|
||||||
|
FROM activity_log
|
||||||
|
WHERE profile_id=%s AND date >= %s
|
||||||
|
GROUP BY week_start
|
||||||
|
ORDER BY week_start""",
|
||||||
|
(profile_id, cutoff),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return {
|
||||||
|
"chart_type": "bar",
|
||||||
|
"data": {"labels": [], "datasets": []},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": "insufficient",
|
||||||
|
"data_points": 0,
|
||||||
|
"message": "Keine Aktivitätsdaten vorhanden",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = [row["week_start"].strftime("KW %V") for row in rows]
|
||||||
|
values = [safe_float(row["total_minutes"]) for row in rows]
|
||||||
|
|
||||||
|
confidence = calculate_confidence(len(rows), weeks * 7, "general")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chart_type": "bar",
|
||||||
|
"data": {
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": "Trainingsminuten",
|
||||||
|
"data": values,
|
||||||
|
"backgroundColor": "#1D9E75",
|
||||||
|
"borderColor": "#085041",
|
||||||
|
"borderWidth": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"metadata": serialize_dates(
|
||||||
|
{
|
||||||
|
"confidence": confidence,
|
||||||
|
"data_points": len(rows),
|
||||||
|
"avg_minutes_week": round(sum(values) / len(values), 1) if values else 0,
|
||||||
|
"total_sessions": sum(row["session_count"] for row in rows),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_training_type_distribution_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Trainingstyp-Verteilung — gleiche Logik wie GET /api/charts/training-type-distribution.
|
||||||
|
"""
|
||||||
|
dist_data = get_training_type_distribution_data(profile_id, days)
|
||||||
|
|
||||||
|
if dist_data["confidence"] == "insufficient":
|
||||||
|
return {
|
||||||
|
"chart_type": "pie",
|
||||||
|
"data": {"labels": [], "datasets": []},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": "insufficient",
|
||||||
|
"data_points": 0,
|
||||||
|
"message": "Keine Trainingstypen-Daten",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = [item["category"] for item in dist_data["distribution"]]
|
||||||
|
values = [item["count"] for item in dist_data["distribution"]]
|
||||||
|
|
||||||
|
colors = [
|
||||||
|
"#1D9E75",
|
||||||
|
"#3B82F6",
|
||||||
|
"#F59E0B",
|
||||||
|
"#EF4444",
|
||||||
|
"#8B5CF6",
|
||||||
|
"#10B981",
|
||||||
|
"#F97316",
|
||||||
|
"#06B6D4",
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chart_type": "pie",
|
||||||
|
"data": {
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"data": values,
|
||||||
|
"backgroundColor": colors[: len(values)],
|
||||||
|
"borderWidth": 2,
|
||||||
|
"borderColor": "#fff",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": dist_data["confidence"],
|
||||||
|
"total_sessions": dist_data["total_sessions"],
|
||||||
|
"categorized_sessions": dist_data["categorized_sessions"],
|
||||||
|
"uncategorized_sessions": dist_data["uncategorized_sessions"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_training_volume_two_week_delta(profile_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Trainingsminuten: letzte 7 Kalendertage vs. die 7 Tage davor (Fortschritt Volumen).
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(duration_min) FILTER (WHERE date >= CURRENT_DATE - INTERVAL '7 days'), 0)::bigint AS last7,
|
||||||
|
COALESCE(SUM(duration_min) FILTER (
|
||||||
|
WHERE date < CURRENT_DATE - INTERVAL '7 days'
|
||||||
|
AND date >= CURRENT_DATE - INTERVAL '14 days'), 0)::bigint AS prev7
|
||||||
|
FROM activity_log
|
||||||
|
WHERE profile_id = %s
|
||||||
|
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
||||||
|
last7 = int(row["last7"] or 0)
|
||||||
|
prev7 = int(row["prev7"] or 0)
|
||||||
|
if last7 == 0 and prev7 == 0:
|
||||||
|
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
||||||
|
delta_pct: Optional[float] = None
|
||||||
|
if prev7 > 0:
|
||||||
|
delta_pct = round((last7 - prev7) / float(prev7) * 100.0, 1)
|
||||||
|
return {
|
||||||
|
"last7_min": last7,
|
||||||
|
"prior7_min": prev7,
|
||||||
|
"delta_pct": delta_pct,
|
||||||
|
"has_data": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_quality_sessions_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||||
|
"""Qualitäts-Sessions vs. regulär — gleiche Logik wie GET /api/charts/quality-sessions."""
|
||||||
|
if days < 7:
|
||||||
|
days = 7
|
||||||
|
if days > 90:
|
||||||
|
days = 90
|
||||||
|
quality_pct = calculate_quality_sessions_pct(profile_id, days)
|
||||||
|
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT COUNT(*) as total
|
||||||
|
FROM activity_log
|
||||||
|
WHERE profile_id=%s AND date >= %s""",
|
||||||
|
(profile_id, cutoff),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
total_sessions = row["total"] if row else 0
|
||||||
|
|
||||||
|
if total_sessions == 0:
|
||||||
|
return {
|
||||||
|
"chart_type": "bar",
|
||||||
|
"data": {"labels": [], "datasets": []},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": "insufficient",
|
||||||
|
"data_points": 0,
|
||||||
|
"message": "Keine Aktivitätsdaten",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
q = float(quality_pct or 0)
|
||||||
|
quality_count = int(round(q / 100.0 * total_sessions))
|
||||||
|
quality_count = max(0, min(quality_count, total_sessions))
|
||||||
|
regular_count = total_sessions - quality_count
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chart_type": "bar",
|
||||||
|
"data": {
|
||||||
|
"labels": ["Qualitäts-Sessions", "Reguläre Sessions"],
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": "Anzahl",
|
||||||
|
"data": [quality_count, regular_count],
|
||||||
|
"backgroundColor": ["#1D9E75", "#888"],
|
||||||
|
"borderColor": "#085041",
|
||||||
|
"borderWidth": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": calculate_confidence(total_sessions, days, "general"),
|
||||||
|
"data_points": total_sessions,
|
||||||
|
"quality_pct": round(q, 1),
|
||||||
|
"quality_count": quality_count,
|
||||||
|
"regular_count": regular_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_load_monitoring_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||||
|
"""Tages-Load-Zeitreihe + ACWR — gleiche Logik wie GET /api/charts/load-monitoring."""
|
||||||
|
if days < 14:
|
||||||
|
days = 14
|
||||||
|
if days > 90:
|
||||||
|
days = 90
|
||||||
|
|
||||||
|
acute_load = calculate_proxy_internal_load_window(profile_id, 7)
|
||||||
|
chronic_load = calculate_proxy_internal_load_window(profile_id, 28)
|
||||||
|
|
||||||
|
acwr = (
|
||||||
|
(acute_load / chronic_load) if acute_load is not None and chronic_load and chronic_load > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT
|
||||||
|
date,
|
||||||
|
SUM(duration_min * COALESCE(rpe, 5)) as daily_load
|
||||||
|
FROM activity_log
|
||||||
|
WHERE profile_id=%s AND date >= %s
|
||||||
|
GROUP BY date
|
||||||
|
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 Load-Daten",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = [row["date"].isoformat() for row in rows]
|
||||||
|
values = [safe_float(row["daily_load"]) for row in rows]
|
||||||
|
|
||||||
|
al = float(acute_load) if acute_load is not None else 0.0
|
||||||
|
cl = float(chronic_load) if chronic_load is not None else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chart_type": "line",
|
||||||
|
"data": {
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": "Tages-Load",
|
||||||
|
"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),
|
||||||
|
"acute_load_7d": round(al, 1),
|
||||||
|
"chronic_load_28d": round(cl, 1),
|
||||||
|
"acwr": round(acwr, 2),
|
||||||
|
"acwr_status": "optimal" if 0.8 <= acwr <= 1.3 else "suboptimal",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
|
||||||
283
backend/data_layer/fitness_interpretation.py
Normal file
283
backend/data_layer/fitness_interpretation.py
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""
|
||||||
|
KPI-Kacheln für Layer-2b Fitness-Dashboard (Issue #53).
|
||||||
|
|
||||||
|
Ausgabe für KpiTilesOverview; ``keys`` = Platzhalter-Registry-Referenzen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 _minutes_status(minutes: Optional[int]) -> str:
|
||||||
|
if minutes is None:
|
||||||
|
return "warn"
|
||||||
|
if 150 <= minutes <= 300:
|
||||||
|
return "good"
|
||||||
|
if minutes < 150:
|
||||||
|
return "warn" if minutes >= 90 else "bad"
|
||||||
|
return "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_status(pct: Optional[int]) -> str:
|
||||||
|
if pct is None:
|
||||||
|
return "warn"
|
||||||
|
if pct >= 60:
|
||||||
|
return "good"
|
||||||
|
if pct >= 40:
|
||||||
|
return "warn"
|
||||||
|
return "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def _score_status(score: Optional[int]) -> str:
|
||||||
|
if score is None:
|
||||||
|
return "warn"
|
||||||
|
if score >= 70:
|
||||||
|
return "good"
|
||||||
|
if score >= 50:
|
||||||
|
return "warn"
|
||||||
|
return "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def _vo2_status(trend: Optional[float]) -> str:
|
||||||
|
if trend is None:
|
||||||
|
return "warn"
|
||||||
|
if trend > 0.5:
|
||||||
|
return "good"
|
||||||
|
if trend >= -0.5:
|
||||||
|
return "warn"
|
||||||
|
return "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def _vol_delta_status(delta_pct: Optional[float], prior7: int, last7: int) -> str:
|
||||||
|
if delta_pct is None:
|
||||||
|
if last7 > 0 and prior7 == 0:
|
||||||
|
return "good"
|
||||||
|
return "warn"
|
||||||
|
if delta_pct >= 5:
|
||||||
|
return "good"
|
||||||
|
if delta_pct >= -10:
|
||||||
|
return "warn"
|
||||||
|
return "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fitness_progress_insights(
|
||||||
|
vol_delta: Dict[str, Any],
|
||||||
|
load_meta: Dict[str, Any],
|
||||||
|
quality_pct: Optional[int],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Kurz-Aussagen für die UI (Layer 2b), keine zweite Datenquelle.
|
||||||
|
"""
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
if vol_delta.get("has_data"):
|
||||||
|
last7 = int(vol_delta.get("last7_min") or 0)
|
||||||
|
prev7 = int(vol_delta.get("prior7_min") or 0)
|
||||||
|
d = vol_delta.get("delta_pct")
|
||||||
|
if d is not None:
|
||||||
|
sign = "+" if d > 0 else ""
|
||||||
|
body = (
|
||||||
|
f"Trainingsminuten letzte 7 Tage ({last7} min) vs. Vorwoche ({prev7} min): "
|
||||||
|
f"{sign}{d} %."
|
||||||
|
)
|
||||||
|
elif last7 > 0 and prev7 == 0:
|
||||||
|
body = f"Mehr Volumen als in der Vorwoche: zuletzt {last7} min (Vorwoche 0 min)."
|
||||||
|
else:
|
||||||
|
body = "Zu wenig Daten für einen Vorwochen-Vergleich."
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"key": "ins_vol_trend",
|
||||||
|
"tone": _vol_delta_status(
|
||||||
|
float(d) if d is not None else None, prev7, last7
|
||||||
|
),
|
||||||
|
"title": "Volumen-Trend",
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
acwr = load_meta.get("acwr")
|
||||||
|
st = load_meta.get("acwr_status")
|
||||||
|
if acwr is not None and isinstance(load_meta, dict) and load_meta.get("data_points", 0) > 0:
|
||||||
|
if st == "optimal":
|
||||||
|
tone = "good"
|
||||||
|
hint = "Akute zu chronischer Last (ACWR) liegt im oft empfohlenen Bereich (ca. 0,8–1,3)."
|
||||||
|
else:
|
||||||
|
tone = "warn"
|
||||||
|
hint = (
|
||||||
|
"ACWR außerhalb des häufig genannten Zielkorridors — bei anhaltender Belastung "
|
||||||
|
"Erholung oder Volumen prüfen (Proxy-Modell)."
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"key": "ins_acwr",
|
||||||
|
"tone": tone,
|
||||||
|
"title": "Belastungsverhältnis (ACWR)",
|
||||||
|
"body": f"Verhältnis akut (7 Tage) zu chronisch (28 Tage): {float(acwr):.2f}. {hint}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if quality_pct is not None:
|
||||||
|
tone = "good" if quality_pct >= 60 else "warn" if quality_pct >= 40 else "bad"
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"key": "ins_quality",
|
||||||
|
"tone": tone,
|
||||||
|
"title": "Session-Qualität",
|
||||||
|
"body": f"{quality_pct} % der Sessions sind als «gut» oder besser eingestuft — Grundlage für progressive Belastung.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_fitness_dashboard_kpi_tiles(
|
||||||
|
summary: Dict[str, Any],
|
||||||
|
minutes_7d: Optional[int],
|
||||||
|
quality_pct: Optional[int],
|
||||||
|
quality_window_days: int,
|
||||||
|
activity_score: Optional[int],
|
||||||
|
vo2_trend: Optional[float],
|
||||||
|
top_focus: Optional[Dict[str, Any]],
|
||||||
|
vol_delta: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
spw = summary.get("sessions_per_week")
|
||||||
|
try:
|
||||||
|
spw_f = float(spw) if spw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
spw_f = None
|
||||||
|
spw_s = f"{spw_f:.1f}".replace(".", ",") if spw_f is not None else "—"
|
||||||
|
|
||||||
|
m_status = _minutes_status(minutes_7d)
|
||||||
|
q_status = _quality_status(quality_pct)
|
||||||
|
s_status = _score_status(activity_score)
|
||||||
|
v_status = _vo2_status(vo2_trend)
|
||||||
|
|
||||||
|
tiles: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if vol_delta and vol_delta.get("has_data"):
|
||||||
|
d = vol_delta.get("delta_pct")
|
||||||
|
last7 = int(vol_delta.get("last7_min") or 0)
|
||||||
|
prev7 = int(vol_delta.get("prior7_min") or 0)
|
||||||
|
if d is not None:
|
||||||
|
sign = "+" if float(d) > 0 else ""
|
||||||
|
v_s = f"{sign}{d:.1f} %".replace(".", ",")
|
||||||
|
sub = f"{last7} min vs. {prev7} min (7-Tage-Fenster)"
|
||||||
|
elif last7 > 0 and prev7 == 0:
|
||||||
|
v_s = "neu"
|
||||||
|
sub = f"{last7} min letzte Woche"
|
||||||
|
else:
|
||||||
|
v_s = "—"
|
||||||
|
sub = "Vergleich Vorwoche"
|
||||||
|
vd_st = _vol_delta_status(float(d) if d is not None else None, prev7, last7)
|
||||||
|
tiles.append(
|
||||||
|
{
|
||||||
|
"key": "volume_vs_prior_week",
|
||||||
|
"category": "Volumen vs. Vorwoche",
|
||||||
|
"icon": "📈",
|
||||||
|
"value": v_s,
|
||||||
|
"sublabel": sub,
|
||||||
|
"status": vd_st,
|
||||||
|
"verdict": _verdict(vd_st),
|
||||||
|
"hoverTop": "Fortschritt Trainingsminuten",
|
||||||
|
"hoverBody": "Letzte 7 Kalendertage vs. die 7 Tage davor (activity_log).",
|
||||||
|
"keys": ["training_minutes_week", "activity_summary"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tiles.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "minutes_week",
|
||||||
|
"category": "Minuten (7 Tage)",
|
||||||
|
"icon": "⏱",
|
||||||
|
"value": f"{minutes_7d} min" if minutes_7d is not None else "—",
|
||||||
|
"sublabel": "WHO: 150–300 min/Woche",
|
||||||
|
"status": m_status,
|
||||||
|
"verdict": _verdict(m_status),
|
||||||
|
"hoverTop": "Summe Trainingsminuten (letzte 7 Tage)",
|
||||||
|
"hoverBody": "Gleiche Quelle wie Platzhalter training_minutes_week.",
|
||||||
|
"keys": ["training_minutes_week", "activity_score"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sessions_per_week",
|
||||||
|
"category": "Sessions / Woche",
|
||||||
|
"icon": "📅",
|
||||||
|
"value": spw_s,
|
||||||
|
"sublabel": f"Fenster: {summary.get('days_analyzed', '—')} Tage",
|
||||||
|
"status": "good",
|
||||||
|
"verdict": "Gut",
|
||||||
|
"hoverTop": "Durchschnittliche Sessions pro Woche",
|
||||||
|
"hoverBody": "Aus activity_summary (activity_log im gewählten Zeitraum).",
|
||||||
|
"keys": ["activity_summary"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "quality_pct",
|
||||||
|
"category": "Qualitätssessions",
|
||||||
|
"icon": "✓",
|
||||||
|
"value": f"{quality_pct} %" if quality_pct is not None else "—",
|
||||||
|
"sublabel": f"Anteil «gut+» · {quality_window_days} Tage",
|
||||||
|
"status": q_status,
|
||||||
|
"verdict": _verdict(q_status),
|
||||||
|
"hoverTop": "Anteil Sessions mit guter Qualitätslabel-Klassifikation",
|
||||||
|
"hoverBody": "Entspricht quality_sessions_pct (Fenster wie gewählt).",
|
||||||
|
"keys": ["quality_sessions_pct"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "activity_score",
|
||||||
|
"category": "Activity-Score",
|
||||||
|
"icon": "🎯",
|
||||||
|
"value": str(activity_score) if activity_score is not None else "—",
|
||||||
|
"sublabel": "Ausrichtung an gewichteten Fokusbereichen",
|
||||||
|
"status": s_status,
|
||||||
|
"verdict": _verdict(s_status) if activity_score is not None else "Hinweis",
|
||||||
|
"hoverTop": "Gewichteter Score (0–100)",
|
||||||
|
"hoverBody": "Ohne gewichtete Aktivitäts-Fokusbereiche kein Score.",
|
||||||
|
"keys": ["activity_score"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vo2_trend",
|
||||||
|
"category": "VO₂max-Trend",
|
||||||
|
"icon": "🫁",
|
||||||
|
"value": f"{vo2_trend:+.1f}" if vo2_trend is not None else "—",
|
||||||
|
"sublabel": "28-Tage-Trend (geschätzt)",
|
||||||
|
"status": v_status,
|
||||||
|
"verdict": _verdict(v_status) if vo2_trend is not None else "Hinweis",
|
||||||
|
"hoverTop": "Trend der VO₂max-Schätzung aus Aktivitätsdaten",
|
||||||
|
"hoverBody": "Wie vo2max_trend_28d im Data Layer.",
|
||||||
|
"keys": ["vo2max_trend_28d"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if top_focus:
|
||||||
|
prog = top_focus.get("progress")
|
||||||
|
prog_s = f"{prog} %" if prog is not None else "—"
|
||||||
|
w = top_focus.get("weight")
|
||||||
|
try:
|
||||||
|
w_s = f"{float(w):.0f} %" if w is not None else "—"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
w_s = "—"
|
||||||
|
tiles.append(
|
||||||
|
{
|
||||||
|
"key": "top_focus",
|
||||||
|
"category": "Schwerpunkt-Fokus",
|
||||||
|
"icon": "🔭",
|
||||||
|
"value": str(top_focus.get("label") or "—"),
|
||||||
|
"sublabel": f"Fortschritt {prog_s} · Gewicht {w_s}",
|
||||||
|
"status": "good",
|
||||||
|
"verdict": "Gut",
|
||||||
|
"hoverTop": "Höchstgewichteter Fokusbereich",
|
||||||
|
"hoverBody": "Aus focus_area_definitions + Nutzer-Gewichtungen.",
|
||||||
|
"keys": ["top_focus_area_name", "top_focus_area_progress"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return tiles
|
||||||
148
backend/data_layer/fitness_viz.py
Normal file
148
backend/data_layer/fitness_viz.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""
|
||||||
|
Layer 2b: Fitness-Hub — ein Bundle für die Aktivitäts-/Fitness-UI (Issue #53).
|
||||||
|
|
||||||
|
Single Source: activity_metrics + dieselben Hilfsfunktionen wie Chart-Endpunkte A1/A2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
from data_layer.activity_metrics import (
|
||||||
|
build_load_monitoring_chart_payload,
|
||||||
|
build_quality_sessions_chart_payload,
|
||||||
|
build_training_type_distribution_chart_payload,
|
||||||
|
build_training_volume_chart_payload,
|
||||||
|
calculate_activity_score,
|
||||||
|
calculate_training_minutes_week,
|
||||||
|
calculate_quality_sessions_pct,
|
||||||
|
calculate_vo2max_trend_28d,
|
||||||
|
get_activity_summary_data,
|
||||||
|
get_training_volume_two_week_delta,
|
||||||
|
)
|
||||||
|
from data_layer.fitness_interpretation import (
|
||||||
|
build_fitness_dashboard_kpi_tiles,
|
||||||
|
build_fitness_progress_insights,
|
||||||
|
)
|
||||||
|
from data_layer.scores import get_top_focus_area
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(d: Any) -> Optional[str]:
|
||||||
|
if d is None:
|
||||||
|
return None
|
||||||
|
if hasattr(d, "isoformat"):
|
||||||
|
return d.isoformat()[:10]
|
||||||
|
return str(d)[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_activity_entries(profile_id: str) -> bool:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM activity_log WHERE profile_id=%s LIMIT 1",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _last_activity_date(profile_id: str) -> Optional[str]:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT MAX(date) AS d FROM activity_log WHERE profile_id=%s",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row or row["d"] is None:
|
||||||
|
return None
|
||||||
|
return _iso(row["d"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Bundle für Fitness-Übersicht: KPI-Kacheln + eingebettete Chart-Payloads (Chart.js-Format).
|
||||||
|
|
||||||
|
``days``: Analysefenster für Zusammenfassung; >=9999 = lange Historie (max. 3650 Tage).
|
||||||
|
"""
|
||||||
|
if not _has_activity_entries(profile_id):
|
||||||
|
return {
|
||||||
|
"confidence": "insufficient",
|
||||||
|
"has_activity_entries": False,
|
||||||
|
"message": "Noch keine Aktivitätsdaten",
|
||||||
|
"kpi_tiles": [],
|
||||||
|
"summary": {},
|
||||||
|
"progress_insights": [],
|
||||||
|
"volume_delta": {},
|
||||||
|
"charts": {},
|
||||||
|
"meta": {"layer_1": "activity_metrics", "layer_2b": "fitness_viz"},
|
||||||
|
}
|
||||||
|
|
||||||
|
all_history = days >= 9999
|
||||||
|
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
||||||
|
|
||||||
|
summary = get_activity_summary_data(profile_id, eff_days)
|
||||||
|
|
||||||
|
weeks_vol = max(4, min(52, (min(eff_days, 365) + 6) // 7))
|
||||||
|
dist_days = min(90, max(7, min(eff_days, 365)))
|
||||||
|
load_days = min(90, max(14, min(eff_days, 365)))
|
||||||
|
|
||||||
|
volume_chart = build_training_volume_chart_payload(profile_id, weeks_vol)
|
||||||
|
type_chart = build_training_type_distribution_chart_payload(profile_id, dist_days)
|
||||||
|
quality_chart = build_quality_sessions_chart_payload(profile_id, dist_days)
|
||||||
|
load_chart = build_load_monitoring_chart_payload(profile_id, load_days)
|
||||||
|
|
||||||
|
quality_days = dist_days
|
||||||
|
quality_pct = calculate_quality_sessions_pct(profile_id, quality_days)
|
||||||
|
minutes_7d = calculate_training_minutes_week(profile_id)
|
||||||
|
activity_score = calculate_activity_score(profile_id)
|
||||||
|
vo2_trend = calculate_vo2max_trend_28d(profile_id)
|
||||||
|
top_focus = get_top_focus_area(profile_id)
|
||||||
|
vol_delta = get_training_volume_two_week_delta(profile_id)
|
||||||
|
|
||||||
|
kpi_tiles = build_fitness_dashboard_kpi_tiles(
|
||||||
|
summary,
|
||||||
|
minutes_7d,
|
||||||
|
quality_pct,
|
||||||
|
quality_days,
|
||||||
|
activity_score,
|
||||||
|
vo2_trend,
|
||||||
|
top_focus,
|
||||||
|
vol_delta,
|
||||||
|
)
|
||||||
|
|
||||||
|
load_meta = load_chart.get("metadata") or {}
|
||||||
|
if not isinstance(load_meta, dict):
|
||||||
|
load_meta = {}
|
||||||
|
progress_insights = build_fitness_progress_insights(vol_delta, load_meta, quality_pct)
|
||||||
|
|
||||||
|
conf = summary.get("confidence") or "medium"
|
||||||
|
if summary.get("activity_count", 0) == 0:
|
||||||
|
conf = "insufficient"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"confidence": conf,
|
||||||
|
"has_activity_entries": True,
|
||||||
|
"days_requested": days,
|
||||||
|
"effective_window_days": eff_days,
|
||||||
|
"training_volume_weeks_used": weeks_vol,
|
||||||
|
"training_type_dist_days_used": dist_days,
|
||||||
|
"last_updated": _last_activity_date(profile_id),
|
||||||
|
"summary": summary,
|
||||||
|
"kpi_tiles": kpi_tiles,
|
||||||
|
"interpretation_tiles": [],
|
||||||
|
"progress_insights": progress_insights,
|
||||||
|
"volume_delta": vol_delta,
|
||||||
|
"charts": {
|
||||||
|
"training_volume": volume_chart,
|
||||||
|
"training_type_distribution": type_chart,
|
||||||
|
"quality_sessions": quality_chart,
|
||||||
|
"load_monitoring": load_chart,
|
||||||
|
},
|
||||||
|
"load_chart_days_used": load_days,
|
||||||
|
"meta": {
|
||||||
|
"layer_1": "activity_metrics",
|
||||||
|
"layer_2b": "fitness_viz",
|
||||||
|
"issue": "53-layer-2b-fitness",
|
||||||
|
},
|
||||||
|
}
|
||||||
456
backend/data_layer/recovery_chart_payloads.py
Normal file
456
backend/data_layer/recovery_chart_payloads.py
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
"""
|
||||||
|
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, duration_minutes
|
||||||
|
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["duration_minutes"]) / 60 if row["duration_minutes"] 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("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, duration_minutes
|
||||||
|
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["duration_minutes"]) / 60 if row["duration_minutes"] 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 measured_at::date >= %s::date
|
||||||
|
ORDER BY measured_at 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,15 @@ from data_layer.body_metrics import (
|
||||||
)
|
)
|
||||||
from data_layer.body_viz import get_body_history_viz_bundle
|
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.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 (
|
from data_layer.nutrition_metrics import (
|
||||||
get_nutrition_average_data,
|
get_nutrition_average_data,
|
||||||
get_protein_targets_data,
|
get_protein_targets_data,
|
||||||
|
|
@ -44,13 +53,14 @@ from data_layer.nutrition_metrics import (
|
||||||
)
|
)
|
||||||
from data_layer.activity_metrics import (
|
from data_layer.activity_metrics import (
|
||||||
get_activity_summary_data,
|
get_activity_summary_data,
|
||||||
get_training_type_distribution_data,
|
|
||||||
calculate_training_minutes_week,
|
calculate_training_minutes_week,
|
||||||
calculate_quality_sessions_pct,
|
|
||||||
calculate_proxy_internal_load_7d,
|
|
||||||
calculate_monotony_score,
|
calculate_monotony_score,
|
||||||
calculate_strain_score,
|
calculate_strain_score,
|
||||||
calculate_ability_balance
|
calculate_ability_balance,
|
||||||
|
build_training_volume_chart_payload,
|
||||||
|
build_training_type_distribution_chart_payload,
|
||||||
|
build_quality_sessions_chart_payload,
|
||||||
|
build_load_monitoring_chart_payload,
|
||||||
)
|
)
|
||||||
from data_layer.recovery_metrics import (
|
from data_layer.recovery_metrics import (
|
||||||
get_sleep_duration_data,
|
get_sleep_duration_data,
|
||||||
|
|
@ -288,6 +298,44 @@ def get_nutrition_history_viz(
|
||||||
return serialize_dates(bundle)
|
return serialize_dates(bundle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness-dashboard-viz")
|
||||||
|
def get_fitness_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: Fitness-Übersicht — KPI-Kacheln + Volumen- und Typ-Verteilungs-Charts.
|
||||||
|
|
||||||
|
Daten aus activity_metrics (gleiche Payloads wie training-volume / training-type-distribution).
|
||||||
|
"""
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
bundle = get_fitness_dashboard_viz_bundle(profile_id, days)
|
||||||
|
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")
|
@router.get("/circumferences")
|
||||||
def get_circumferences_chart(
|
def get_circumferences_chart(
|
||||||
max_age_days: int = Query(default=90, ge=7, le=365),
|
max_age_days: int = Query(default=90, ge=7, le=365),
|
||||||
|
|
@ -1051,66 +1099,7 @@ def get_training_volume_chart(
|
||||||
Chart.js bar chart with weekly training minutes
|
Chart.js bar chart with weekly training minutes
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_training_volume_chart_payload(profile_id, weeks)
|
||||||
from db import get_db, get_cursor
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Get weekly aggregates
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
DATE_TRUNC('week', date) as week_start,
|
|
||||||
SUM(duration_min) as total_minutes,
|
|
||||||
COUNT(*) as session_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY week_start
|
|
||||||
ORDER BY week_start""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": []
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Aktivitätsdaten vorhanden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row['week_start'].strftime('KW %V') for row in rows]
|
|
||||||
values = [safe_float(row['total_minutes']) for row in rows]
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(rows), weeks * 7, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Trainingsminuten",
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": "#1D9E75",
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates({
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_minutes_week": round(sum(values) / len(values), 1) if values else 0,
|
|
||||||
"total_sessions": sum(row['session_count'] for row in rows)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-type-distribution")
|
@router.get("/training-type-distribution")
|
||||||
|
|
@ -1131,52 +1120,7 @@ def get_training_type_distribution_chart(
|
||||||
Chart.js pie chart with training categories
|
Chart.js pie chart with training categories
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_training_type_distribution_chart_payload(profile_id, days)
|
||||||
dist_data = get_training_type_distribution_data(profile_id, days)
|
|
||||||
|
|
||||||
if dist_data['confidence'] == 'insufficient':
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": []
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Trainingstypen-Daten"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [item['category'] for item in dist_data['distribution']]
|
|
||||||
values = [item['count'] for item in dist_data['distribution']]
|
|
||||||
|
|
||||||
# Color palette for training categories
|
|
||||||
colors = [
|
|
||||||
"#1D9E75", "#3B82F6", "#F59E0B", "#EF4444",
|
|
||||||
"#8B5CF6", "#10B981", "#F97316", "#06B6D4"
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": colors[:len(values)],
|
|
||||||
"borderWidth": 2,
|
|
||||||
"borderColor": "#fff"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": dist_data['confidence'],
|
|
||||||
"total_sessions": dist_data['total_sessions'],
|
|
||||||
"categorized_sessions": dist_data['categorized_sessions'],
|
|
||||||
"uncategorized_sessions": dist_data['uncategorized_sessions']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/quality-sessions")
|
@router.get("/quality-sessions")
|
||||||
|
|
@ -1197,63 +1141,7 @@ def get_quality_sessions_chart(
|
||||||
Chart.js bar chart with quality metrics
|
Chart.js bar chart with quality metrics
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_quality_sessions_chart_payload(profile_id, days)
|
||||||
# Calculate quality session percentage
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id, days)
|
|
||||||
|
|
||||||
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 COUNT(*) as total
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
total_sessions = row['total'] if row else 0
|
|
||||||
|
|
||||||
if total_sessions == 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": []
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Aktivitätsdaten"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
quality_count = int(quality_pct / 100 * total_sessions)
|
|
||||||
regular_count = total_sessions - quality_count
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": ["Qualitäts-Sessions", "Reguläre Sessions"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Anzahl",
|
|
||||||
"data": [quality_count, regular_count],
|
|
||||||
"backgroundColor": ["#1D9E75", "#888"],
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": calculate_confidence(total_sessions, days, "general"),
|
|
||||||
"data_points": total_sessions,
|
|
||||||
"quality_pct": round(quality_pct, 1),
|
|
||||||
"quality_count": quality_count,
|
|
||||||
"regular_count": regular_count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/load-monitoring")
|
@router.get("/load-monitoring")
|
||||||
|
|
@ -1274,74 +1162,7 @@ def get_load_monitoring_chart(
|
||||||
Chart.js line chart with load metrics
|
Chart.js line chart with load metrics
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_load_monitoring_chart_payload(profile_id, days)
|
||||||
# Calculate loads
|
|
||||||
acute_load = calculate_proxy_internal_load_7d(profile_id)
|
|
||||||
chronic_load = calculate_proxy_internal_load_7d(profile_id, days=28)
|
|
||||||
|
|
||||||
# ACWR (Acute:Chronic Workload Ratio)
|
|
||||||
acwr = acute_load / chronic_load if chronic_load > 0 else 0
|
|
||||||
|
|
||||||
# Fetch daily loads for timeline
|
|
||||||
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,
|
|
||||||
SUM(duration_min * COALESCE(rpe, 5)) as daily_load
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY date
|
|
||||||
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 Load-Daten"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row['date'].isoformat() for row in rows]
|
|
||||||
values = [safe_float(row['daily_load']) for row in rows]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Tages-Load",
|
|
||||||
"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),
|
|
||||||
"acute_load_7d": round(acute_load, 1),
|
|
||||||
"chronic_load_28d": round(chronic_load, 1),
|
|
||||||
"acwr": round(acwr, 2),
|
|
||||||
"acwr_status": "optimal" if 0.8 <= acwr <= 1.3 else "suboptimal"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monotony-strain")
|
@router.get("/monotony-strain")
|
||||||
|
|
@ -1573,106 +1394,9 @@ def get_recovery_score_chart(
|
||||||
days: int = Query(default=28, ge=7, le=90),
|
days: int = Query(default=28, ge=7, le=90),
|
||||||
session: dict = Depends(require_auth)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""Recovery score timeline (R1). Delegiert an recovery_chart_payloads."""
|
||||||
Recovery score timeline (R1).
|
profile_id = session["profile_id"]
|
||||||
|
return build_recovery_score_chart_payload(profile_id, days)
|
||||||
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"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hrv-rhr-baseline")
|
@router.get("/hrv-rhr-baseline")
|
||||||
|
|
@ -1680,101 +1404,9 @@ def get_hrv_rhr_baseline_chart(
|
||||||
days: int = Query(default=28, ge=7, le=90),
|
days: int = Query(default=28, ge=7, le=90),
|
||||||
session: dict = Depends(require_auth)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""HRV/RHR vs baseline (R2)."""
|
||||||
HRV/RHR vs baseline (R2).
|
profile_id = session["profile_id"]
|
||||||
|
return build_hrv_rhr_baseline_chart_payload(profile_id, days)
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sleep-duration-quality")
|
@router.get("/sleep-duration-quality")
|
||||||
|
|
@ -1782,107 +1414,9 @@ def get_sleep_duration_quality_chart(
|
||||||
days: int = Query(default=28, ge=7, le=90),
|
days: int = Query(default=28, ge=7, le=90),
|
||||||
session: dict = Depends(require_auth)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""Sleep duration + quality (R3)."""
|
||||||
Sleep duration + quality (R3).
|
profile_id = session["profile_id"]
|
||||||
|
return build_sleep_duration_quality_chart_payload(profile_id, days)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sleep-debt")
|
@router.get("/sleep-debt")
|
||||||
|
|
@ -1890,100 +1424,9 @@ def get_sleep_debt_chart(
|
||||||
days: int = Query(default=28, ge=7, le=90),
|
days: int = Query(default=28, ge=7, le=90),
|
||||||
session: dict = Depends(require_auth)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""Sleep debt (R4)."""
|
||||||
Sleep debt accumulation (R4).
|
profile_id = session["profile_id"]
|
||||||
|
return build_sleep_debt_chart_payload(profile_id, days)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vital-signs-matrix")
|
@router.get("/vital-signs-matrix")
|
||||||
|
|
@ -1991,123 +1434,9 @@ def get_vital_signs_matrix_chart(
|
||||||
days: int = Query(default=7, ge=7, le=30),
|
days: int = Query(default=7, ge=7, le=30),
|
||||||
session: dict = Depends(require_auth)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""Vital signs matrix (R5)."""
|
||||||
Vital signs matrix (R5).
|
profile_id = session["profile_id"]
|
||||||
|
return build_vital_signs_matrix_chart_payload(profile_id, days)
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Correlation Charts ──────────────────────────────────────────────────────
|
# ── Correlation Charts ──────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ Dieser Ordner ist **immer mit Git versioniert**. Er ergänzt **`.claude/docs/`**
|
||||||
| `issue-51-prompt-page-assignment.md` |
|
| `issue-51-prompt-page-assignment.md` |
|
||||||
| `issue-52-blood-pressure-dual-targets.md` |
|
| `issue-52-blood-pressure-dual-targets.md` |
|
||||||
| `issue-53-phase-0c-multi-layer-architecture.md` |
|
| `issue-53-phase-0c-multi-layer-architecture.md` |
|
||||||
|
| `issue-fitness-dashboard-layer2b.md` |
|
||||||
| `issue-54-dynamic-placeholder-system.md` |
|
| `issue-54-dynamic-placeholder-system.md` |
|
||||||
| `issue-55-dynamic-aggregation-methods.md` |
|
| `issue-55-dynamic-aggregation-methods.md` |
|
||||||
| `issue-76-training-quality-goal-list-filter.md` |
|
| `issue-76-training-quality-goal-list-filter.md` |
|
||||||
|
|
@ -56,4 +57,4 @@ Themen-Übersicht (lokal): **`.claude/docs/GITEA_ISSUES_INDEX.md`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Stand:** 2026-04-08
|
**Stand:** 2026-04-19
|
||||||
|
|
|
||||||
54
docs/issues/issue-fitness-dashboard-layer2b.md
Normal file
54
docs/issues/issue-fitness-dashboard-layer2b.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Fitness-Dashboard (Layer 2b) – Abnahme & technische Zuordnung
|
||||||
|
|
||||||
|
**Status:** umgesetzt (Frontend + Backend)
|
||||||
|
**Bezug:** Issue #53 (Phase 0c) – Layer 1 → Layer 2b Bundle → UI nur Darstellung
|
||||||
|
**Stand:** 2026-04-19
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
- Eine **Fitness-Übersicht** auf **`/history`** (Tab Fitness), analog Körper/Ernährung — **keine parallelen Berechnungen** im Client für Layer 2b.
|
||||||
|
- **Single Source of Truth:** `data_layer/activity_metrics` (und Scores/Focus wie bei den Platzhaltern), identische Chart-Payloads wie die bestehenden Chart-Endpunkte A1/A2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
| Bestandteil | Pfad / Endpoint |
|
||||||
|
|-------------|-----------------|
|
||||||
|
| Chart-Payloads (A1/A2) | `build_training_volume_chart_payload`, `build_training_type_distribution_chart_payload` in `backend/data_layer/activity_metrics.py` |
|
||||||
|
| KPI-Kacheln (Struktur für UI) | `backend/data_layer/fitness_interpretation.py` → `build_fitness_dashboard_kpi_tiles` |
|
||||||
|
| Bundle | `backend/data_layer/fitness_viz.py` → `get_fitness_dashboard_viz_bundle(profile_id, days)` |
|
||||||
|
| API | `GET /api/charts/fitness-dashboard-viz?days=7…9999` in `backend/routers/charts.py` |
|
||||||
|
|
||||||
|
**Hinweise:**
|
||||||
|
|
||||||
|
- `days >= 9999` wählt eine **lange Historie** für die Zusammenfassung (analog Ernährungs-Bundle).
|
||||||
|
- `calculate_quality_sessions_pct(profile_id, days)` unterstützt ein variables Fenster (wird auch vom Quality-Chart genutzt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
| Bestandteil | Pfad |
|
||||||
|
|-------------|------|
|
||||||
|
| API-Client | `getFitnessDashboardViz(days)` in `frontend/src/utils/api.js` |
|
||||||
|
| Darstellung | `frontend/src/components/FitnessDashboardOverview.jsx` |
|
||||||
|
| Einbindung | `frontend/src/pages/History.jsx` → `ActivitySection` (gemeinsamer `PeriodSelector` wie die Liste darunter) |
|
||||||
|
| Erfassung | `/activity` bleibt reine Erfassung; Capture-Hub-Label **Aktivität** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erweiterungen (optional)
|
||||||
|
|
||||||
|
- Weitere Charts aus A5–A8 ins Bundle (Monotonie, Fähigkeiten …), gleiches Muster: Builder in `activity_metrics`, Router nur delegieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahme-Checkliste
|
||||||
|
|
||||||
|
- [x] Bundle liefert u. a. `has_activity_entries`, `summary`, `kpi_tiles`, `progress_insights`, `volume_delta`, `charts.training_volume`, `charts.training_type_distribution`, `charts.quality_sessions`, `charts.load_monitoring`, `load_chart_days_used`, `meta`.
|
||||||
|
- [x] Verlauf `/history` → Fitness: **keine** zweiten Charts/KPIs aus `activities`-Liste (keine Redundanz zur Erfassungs-API).
|
||||||
|
- [x] Chart-Endpunkte A3/A4 nutzen dieselben Builder wie das Bundle (`build_quality_sessions_chart_payload`, `build_load_monitoring_chart_payload`).
|
||||||
|
- [x] `calculate_proxy_internal_load_window` ersetzt fehlerhaften `days=28`-Aufruf an der alten 7-Tage-Funktion (chronische Last).
|
||||||
348
frontend/src/components/FitnessDashboardOverview.jsx
Normal file
348
frontend/src/components/FitnessDashboardOverview.jsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
CartesianGrid,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import KpiTilesOverview from './KpiTilesOverview'
|
||||||
|
import { getStatusColor } from '../utils/interpret'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ v: 7, label: '7 Tage' },
|
||||||
|
{ v: 28, label: '28 Tage' },
|
||||||
|
{ v: 90, label: '90 Tage' },
|
||||||
|
{ v: 9999, label: 'Gesamt' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics).
|
||||||
|
*/
|
||||||
|
export default function FitnessDashboardOverview({
|
||||||
|
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
|
||||||
|
.getFitnessDashboardViz(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">Fitness-Übersicht</div>
|
||||||
|
<div className="spinner" style={{ margin: 24 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Fitness-Übersicht</div>
|
||||||
|
<div style={{ color: 'var(--danger)' }}>{err}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viz?.has_activity_entries) {
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Fitness-Übersicht</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
||||||
|
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => nav('/activity')}>
|
||||||
|
Zur Erfassung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vol = viz.charts?.training_volume
|
||||||
|
const typ = viz.charts?.training_type_distribution
|
||||||
|
const qual = viz.charts?.quality_sessions
|
||||||
|
const loadCh = viz.charts?.load_monitoring
|
||||||
|
|
||||||
|
const volRows = (vol?.data?.labels || []).map((name, i) => ({
|
||||||
|
name,
|
||||||
|
min: vol?.data?.datasets?.[0]?.data?.[i] ?? 0,
|
||||||
|
}))
|
||||||
|
const pieLabels = typ?.data?.labels || []
|
||||||
|
const pieVals = typ?.data?.datasets?.[0]?.data || []
|
||||||
|
const pieColors = typ?.data?.datasets?.[0]?.backgroundColor || []
|
||||||
|
const pieData = pieLabels.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
value: pieVals[i],
|
||||||
|
fill: pieColors[i] || '#888780',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const qualLabels = qual?.data?.labels || []
|
||||||
|
const qualVals = qual?.data?.datasets?.[0]?.data || []
|
||||||
|
const qualBg = qual?.data?.datasets?.[0]?.backgroundColor || []
|
||||||
|
const qualBar = qualLabels.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
n: qualVals[i] ?? 0,
|
||||||
|
fill: qualBg[i] || '#1D9E75',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const loadLabels = loadCh?.data?.labels || []
|
||||||
|
const loadVals = loadCh?.data?.datasets?.[0]?.data || []
|
||||||
|
const loadRows = loadLabels.map((iso, i) => ({
|
||||||
|
t: dayjs(iso).format('DD.MM.'),
|
||||||
|
load: loadVals[i] ?? 0,
|
||||||
|
}))
|
||||||
|
const loadMeta = loadCh?.metadata || {}
|
||||||
|
|
||||||
|
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 wUsed = viz.training_volume_weeks_used
|
||||||
|
const dTyp = viz.training_type_dist_days_used
|
||||||
|
const loadDays = viz.load_chart_days_used
|
||||||
|
|
||||||
|
const showPeriodDropdown = !hidePeriodSelector && !controlled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span>Fitness-Übersicht</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))}
|
||||||
|
>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<option key={p.v} value={p.v}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||||
|
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '}
|
||||||
|
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '}
|
||||||
|
<strong>{loadDays ?? '—'}</strong> Tage
|
||||||
|
{viz.last_updated ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· letzte Aktivität <strong>{viz.last_updated}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
.
|
||||||
|
</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={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Trainingsvolumen (Minuten / Woche)
|
||||||
|
</div>
|
||||||
|
{volRows.length >= 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={0}
|
||||||
|
angle={-35}
|
||||||
|
textAnchor="end"
|
||||||
|
height={48}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Training nach Kategorie
|
||||||
|
</div>
|
||||||
|
{pieData.length >= 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={72}
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Qualitäts-Sessions (Schätzung)
|
||||||
|
</div>
|
||||||
|
{qualBar.length >= 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="n" radius={[3, 3, 0, 0]}>
|
||||||
|
{qualBar.map((entry, i) => (
|
||||||
|
<Cell key={`q-${i}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ gridColumn: '1 / -1', maxWidth: '100%' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Belastung (Proxy-Load · duration×RPE / Tag)
|
||||||
|
</div>
|
||||||
|
{loadRows.length >= 1 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
|
<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="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
|
||||||
|
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
|
||||||
|
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy)
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,320 +1,8 @@
|
||||||
import { useState, useEffect } from 'react'
|
import RecoveryDashboardOverview from './RecoveryDashboardOverview'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recovery Charts Component (R1-R5)
|
* @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days → period).
|
||||||
*
|
|
||||||
* 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)
|
|
||||||
*/
|
*/
|
||||||
export default function RecoveryCharts({ days = 28 }) {
|
export default function RecoveryCharts({ days = 28 }) {
|
||||||
const [recoveryData, setRecoveryData] = useState(null)
|
return <RecoveryDashboardOverview period={days} hidePeriodSelector />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { useNavigate } from 'react-router-dom'
|
||||||
import RecoveryCharts from '../RecoveryCharts'
|
import RecoveryDashboardOverview from '../RecoveryDashboardOverview'
|
||||||
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
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
|
* @param {{ refreshTick?: number, chartDays?: number }} props
|
||||||
*/
|
*/
|
||||||
export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }) {
|
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
|
const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 28
|
||||||
|
|
||||||
return (
|
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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
<div>
|
<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 style={{ fontSize: 12, color: 'var(--text3)' }}>Schlaf, Recovery, Vitalwerte · {days} Tage</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: 12, padding: '6px 12px' }}
|
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||||
onClick={() => nav('/history', { state: { tab: 'recovery' } })}
|
onClick={() => nav('/history', { state: { tab: 'activity' } })}
|
||||||
>
|
>
|
||||||
Verlauf →
|
Verlauf →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<RecoveryCharts key={`${refreshTick}-${days}`} days={days} />
|
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} period={days} hidePeriodSelector />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import { getBfCategory } from '../utils/calc'
|
||||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
|
||||||
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
||||||
import RecoveryCharts from '../components/RecoveryCharts'
|
import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview'
|
||||||
import KpiTilesOverview from '../components/KpiTilesOverview'
|
import KpiTilesOverview from '../components/KpiTilesOverview'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
|
|
@ -328,10 +328,10 @@ function InsightBox({ insights, slugs, onRequest, loading }) {
|
||||||
const [expanded, setExpanded] = useState(null)
|
const [expanded, setExpanded] = useState(null)
|
||||||
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
||||||
const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung',
|
const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung',
|
||||||
aktivitaet:'Aktivität',gesundheit:'Gesundheit',ziele:'Ziele',
|
aktivitaet:'Fitness',gesundheit:'Gesundheit',ziele:'Ziele',
|
||||||
pipeline:'🔬 Mehrstufige Analyse',
|
pipeline:'🔬 Mehrstufige Analyse',
|
||||||
pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung',
|
pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung',
|
||||||
pipeline_activity:'Pipeline Aktivität',pipeline_synthesis:'Pipeline Synthese',
|
pipeline_activity:'Pipeline Fitness',pipeline_synthesis:'Pipeline Synthese',
|
||||||
pipeline_goals:'Pipeline Ziele'}
|
pipeline_goals:'Pipeline Ziele'}
|
||||||
return (
|
return (
|
||||||
<div style={{marginTop:14}}>
|
<div style={{marginTop:14}}>
|
||||||
|
|
@ -535,7 +535,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
||||||
<BodyGoalsStrip grouped={groupedGoals} />
|
<BodyGoalsStrip grouped={groupedGoals} />
|
||||||
|
|
||||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||||
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf → Aktivität</strong>.
|
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf → Fitness</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{viz?.meta?.layer_2a_alignment && (
|
{viz?.meta?.layer_2a_alignment && (
|
||||||
|
|
@ -1097,48 +1097,30 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Activity Section ──────────────────────────────────────────────────────────
|
// ── Activity Section — nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts ─
|
||||||
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
||||||
const [period, setPeriod] = useState(30)
|
const [period, setPeriod] = useState(30)
|
||||||
if (!activities?.length) return (
|
const actList = activities || []
|
||||||
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
|
const hasList = actList.length > 0
|
||||||
)
|
|
||||||
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
|
||||||
|
|
||||||
// Issue #31: Backend already filters by global quality level - only filter by period here
|
|
||||||
const filtA = activities.filter(d => period === 9999 || d.date >= cutoff)
|
|
||||||
|
|
||||||
const byDate={}
|
|
||||||
filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) })
|
|
||||||
const cd=Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).map(([date,kcal])=>({date:fmtDate(date),kcal:Math.round(kcal)}))
|
|
||||||
|
|
||||||
const totalKcal=Math.round(filtA.reduce((s,a)=>s+(a.kcal_active||0),0))
|
|
||||||
const totalMin =Math.round(filtA.reduce((s,a)=>s+(a.duration_min||0),0))
|
|
||||||
const hrData =filtA.filter(a=>a.hr_avg)
|
|
||||||
const avgHr =hrData.length?Math.round(hrData.reduce((s,a)=>s+a.hr_avg,0)/hrData.length):null
|
|
||||||
const types={}; filtA.forEach(a=>{ types[a.activity_type]=(types[a.activity_type]||0)+1 })
|
|
||||||
const topTypes=Object.entries(types).sort((a,b)=>b[1]-a[1])
|
|
||||||
|
|
||||||
const daysWithAct=new Set(filtA.map(a=>a.date)).size
|
|
||||||
const totalDays=Math.min(period,dayjs().diff(dayjs(filtA[filtA.length-1]?.date),'day')+1)
|
|
||||||
const consistency=totalDays>0?Math.round(daysWithAct/totalDays*100):0
|
|
||||||
const actRules=[{
|
|
||||||
status:consistency>=70?'good':consistency>=40?'warn':'bad',
|
|
||||||
icon:'📅', category:'Konsistenz',
|
|
||||||
title:`${consistency}% aktive Tage (${daysWithAct}/${Math.min(period,30)} Tage)`,
|
|
||||||
detail:consistency>=70?'Ausgezeichnete Regelmäßigkeit.':consistency>=40?'Ziel: 4–5 Einheiten/Woche.':'Mehr Regelmäßigkeit empfohlen.',
|
|
||||||
value:consistency+'%'
|
|
||||||
}]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
|
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={actList[0]?.date}/>
|
||||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||||
|
Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.
|
||||||
|
</p>
|
||||||
|
<FitnessDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
|
||||||
|
|
||||||
{/* Issue #31: Show active global quality filter */}
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 20 }}>
|
||||||
{globalQualityLevel && globalQualityLevel !== 'all' && (
|
Erholung (Schlaf, HRV, Vitalwerte)
|
||||||
|
</div>
|
||||||
|
<RecoveryDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
|
||||||
|
|
||||||
|
{hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom:12, padding:'8px 12px', borderRadius:8,
|
marginTop: 12,
|
||||||
|
marginBottom: 12, padding:'8px 12px', borderRadius:8,
|
||||||
background:'var(--surface2)', border:'1px solid var(--border)',
|
background:'var(--surface2)', border:'1px solid var(--border)',
|
||||||
fontSize:12, color:'var(--text2)', display:'flex', alignItems:'center', gap:8
|
fontSize:12, color:'var(--text2)', display:'flex', alignItems:'center', gap:8
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -1156,49 +1138,12 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
<InsightBox
|
||||||
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
|
insights={insights}
|
||||||
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
|
slugs={filterActiveSlugs(['aktivitaet', 'gesundheit'])}
|
||||||
avgHr?['Ø HF',avgHr+' bpm','#D85A30']:null].filter(Boolean).map(([l,v,c])=>(
|
onRequest={onRequest}
|
||||||
<div key={l} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
loading={loadingSlug}
|
||||||
<div style={{fontSize:14,fontWeight:700,color:c}}>{v}</div>
|
/>
|
||||||
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="card" style={{marginBottom:12}}>
|
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Aktive Kalorien / Tag</div>
|
|
||||||
<ResponsiveContainer width="100%" height={150}>
|
|
||||||
<BarChart data={cd} 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(cd.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}}
|
|
||||||
formatter={v=>[`${v} kcal`]}/>
|
|
||||||
<Bar dataKey="kcal" fill="#EF9F2788" radius={[3,3,0,0]}/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="card" style={{marginBottom:12}}>
|
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingsarten</div>
|
|
||||||
{topTypes.map(([type,count])=>(
|
|
||||||
<div key={type} style={{display:'flex',alignItems:'center',gap:8,padding:'4px 0',borderBottom:'1px solid var(--border)'}}>
|
|
||||||
<div style={{flex:1,fontSize:13}}>{type}</div>
|
|
||||||
<div style={{fontSize:12,color:'var(--text3)'}}>{count}×</div>
|
|
||||||
<div style={{width:Math.max(4,Math.round(count/filtA.length*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="card" style={{marginBottom:12}}>
|
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingstyp-Verteilung</div>
|
|
||||||
<TrainingTypeDistribution days={period === 9999 ? 365 : period} />
|
|
||||||
</div>
|
|
||||||
<div style={{marginBottom:12}}>
|
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
|
||||||
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
|
||||||
</div>
|
|
||||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1494,32 +1439,10 @@ function PhotoGrid() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
// ── 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 = [
|
const TABS = [
|
||||||
{ id:'body', label:'⚖️ Körper' },
|
{ id:'body', label:'⚖️ Körper' },
|
||||||
{ id:'nutrition', label:'🍽️ Ernährung' },
|
{ id:'nutrition', label:'🍽️ Ernährung' },
|
||||||
{ id:'activity', label:'🏋️ Aktivität' },
|
{ id:'activity', label:'🏋️ Fitness' },
|
||||||
{ id:'recovery', label:'😴 Erholung' },
|
|
||||||
{ id:'correlation', label:'🔗 Korrelation' },
|
{ id:'correlation', label:'🔗 Korrelation' },
|
||||||
{ id:'photos', label:'📷 Fotos' },
|
{ id:'photos', label:'📷 Fotos' },
|
||||||
]
|
]
|
||||||
|
|
@ -1559,6 +1482,10 @@ export default function History() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = location.state?.tab
|
const t = location.state?.tab
|
||||||
|
if (t === 'recovery') {
|
||||||
|
setTab('activity')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (t && TABS.some(x => x.id === t)) setTab(t)
|
if (t && TABS.some(x => x.id === t)) setTab(t)
|
||||||
}, [location.state?.tab])
|
}, [location.state?.tab])
|
||||||
|
|
||||||
|
|
@ -1606,7 +1533,6 @@ export default function History() {
|
||||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
||||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...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==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||||||
{tab==='photos' && <PhotoGrid/>}
|
{tab==='photos' && <PhotoGrid/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -639,6 +639,10 @@ export const api = {
|
||||||
getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`),
|
getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`),
|
||||||
/** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */
|
/** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */
|
||||||
getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`),
|
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}`),
|
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
|
||||||
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
|
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
|
||||||
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),
|
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user