diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index ebb1731..ee78809 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -330,24 +330,30 @@ def calculate_training_frequency_7d(profile_id: str) -> Optional[int]: return int(row['session_count']) if row else None -def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]: - """Calculate percentage of quality sessions (good or better) last 28 days""" +def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]: + """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: cur = get_cursor(conn) - cur.execute(""" + cur.execute( + """ SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count FROM activity_log WHERE profile_id = %s - AND date >= CURRENT_DATE - INTERVAL '28 days' - """, (profile_id,)) + AND date >= %s + """, + (profile_id, cutoff), + ) row = cur.fetchone() - if not row or row['total'] == 0: + if not row or row["total"] == 0: return None - pct = (row['quality_count'] / row['total']) * 100 + pct = (row["quality_count"] / row["total"]) * 100 return int(pct) @@ -495,11 +501,12 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]: # 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) - Formula: duration × intensity_factor × quality_factor + Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage). """ + if days < 1: + days = 7 intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0} quality_factors = { 'excellent': 1.15, @@ -512,12 +519,15 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]: with get_db() as conn: cur = get_cursor(conn) - cur.execute(""" + cur.execute( + """ SELECT duration_min, hr_avg, rpe FROM activity_log WHERE profile_id = %s - AND date >= CURRENT_DATE - INTERVAL '7 days' - """, (profile_id,)) + AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day') + """, + (profile_id, days), + ) 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) 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]: @@ -1222,3 +1237,301 @@ def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]: "parameters": rows, "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", + } + ), + } diff --git a/backend/data_layer/fitness_interpretation.py b/backend/data_layer/fitness_interpretation.py new file mode 100644 index 0000000..114ec36 --- /dev/null +++ b/backend/data_layer/fitness_interpretation.py @@ -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 diff --git a/backend/data_layer/fitness_viz.py b/backend/data_layer/fitness_viz.py new file mode 100644 index 0000000..9c8ae03 --- /dev/null +++ b/backend/data_layer/fitness_viz.py @@ -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", + }, + } diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py new file mode 100644 index 0000000..149ad26 --- /dev/null +++ b/backend/data_layer/recovery_chart_payloads.py @@ -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", + }, + } diff --git a/backend/data_layer/recovery_interpretation.py b/backend/data_layer/recovery_interpretation.py new file mode 100644 index 0000000..8be9863 --- /dev/null +++ b/backend/data_layer/recovery_interpretation.py @@ -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 diff --git a/backend/data_layer/recovery_viz.py b/backend/data_layer/recovery_viz.py new file mode 100644 index 0000000..2decd00 --- /dev/null +++ b/backend/data_layer/recovery_viz.py @@ -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", + }, + } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index fca7c36..508cff0 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -33,6 +33,15 @@ from data_layer.body_metrics import ( ) from data_layer.body_viz import get_body_history_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle +from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle +from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle +from data_layer.recovery_chart_payloads import ( + build_recovery_score_chart_payload, + build_hrv_rhr_baseline_chart_payload, + build_sleep_duration_quality_chart_payload, + build_sleep_debt_chart_payload, + build_vital_signs_matrix_chart_payload, +) from data_layer.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, @@ -44,13 +53,14 @@ from data_layer.nutrition_metrics import ( ) from data_layer.activity_metrics import ( get_activity_summary_data, - get_training_type_distribution_data, calculate_training_minutes_week, - calculate_quality_sessions_pct, - calculate_proxy_internal_load_7d, calculate_monotony_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 ( get_sleep_duration_data, @@ -288,6 +298,44 @@ def get_nutrition_history_viz( 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") def get_circumferences_chart( 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 """ 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(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) - }) - } + return build_training_volume_chart_payload(profile_id, weeks) @router.get("/training-type-distribution") @@ -1131,52 +1120,7 @@ def get_training_type_distribution_chart( Chart.js pie chart with training categories """ profile_id = session['profile_id'] - - 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'] - } - } + return build_training_type_distribution_chart_payload(profile_id, days) @router.get("/quality-sessions") @@ -1197,63 +1141,7 @@ def get_quality_sessions_chart( Chart.js bar chart with quality metrics """ profile_id = session['profile_id'] - - # 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 - } - } + return build_quality_sessions_chart_payload(profile_id, days) @router.get("/load-monitoring") @@ -1274,74 +1162,7 @@ def get_load_monitoring_chart( Chart.js line chart with load metrics """ profile_id = session['profile_id'] - - # 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" - }) - } + return build_load_monitoring_chart_payload(profile_id, days) @router.get("/monotony-strain") @@ -1573,106 +1394,9 @@ def get_recovery_score_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Recovery score timeline (R1). - - Shows daily recovery scores over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js line chart with recovery scores - """ - profile_id = session['profile_id'] - - # For PoC: Use current recovery score and create synthetic timeline - # TODO: Store historical recovery scores for true timeline - current_score = calculate_recovery_score_v2(profile_id) - - if current_score is None: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Recovery-Daten vorhanden" - } - } - - # Fetch vitals for timeline approximation - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, resting_hr, hrv_ms - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [datetime.now().strftime('%Y-%m-%d')], - "datasets": [ - { - "label": "Recovery Score", - "data": [current_score], - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": { - "confidence": "low", - "data_points": 1, - "current_score": current_score - } - } - - # Simple proxy: Use HRV as recovery indicator (higher HRV = better recovery) - # This is a placeholder until we store actual recovery scores - labels = [row['date'].isoformat() for row in rows] - # Normalize HRV to 0-100 scale (assume typical range 20-100ms) - values = [min(100, max(0, safe_float(row['hrv_ms']) if row['hrv_ms'] else 50)) for row in rows] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Recovery Score (proxy)", - "data": values, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "current_score": current_score, - "note": "Score based on HRV proxy; true recovery score calculation in development" - }) - } + """Recovery score timeline (R1). Delegiert an recovery_chart_payloads.""" + profile_id = session["profile_id"] + return build_recovery_score_chart_payload(profile_id, days) @router.get("/hrv-rhr-baseline") @@ -1680,101 +1404,9 @@ def get_hrv_rhr_baseline_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - HRV/RHR vs baseline (R2). - - Shows HRV and RHR trends vs. baseline values. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js multi-line chart with HRV and RHR - """ - profile_id = session['profile_id'] - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, resting_hr, hrv_ms - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Vitalwerte vorhanden" - } - } - - labels = [row['date'].isoformat() for row in rows] - hrv_values = [safe_float(row['hrv_ms']) if row['hrv_ms'] else None for row in rows] - rhr_values = [safe_float(row['resting_hr']) if row['resting_hr'] else None for row in rows] - - # Calculate baselines (28d median) - hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id) # This returns % deviation - rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id) # This returns % deviation - - # For chart, we need actual baseline values (approximation) - hrv_filtered = [v for v in hrv_values if v is not None] - rhr_filtered = [v for v in rhr_values if v is not None] - - avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50 - avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60 - - datasets = [ - { - "label": "HRV (ms)", - "data": hrv_values, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y1", - "fill": False - }, - { - "label": "RHR (bpm)", - "data": rhr_values, - "borderColor": "#3B82F6", - "backgroundColor": "rgba(59, 130, 246, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y2", - "fill": False - } - ] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": datasets - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "avg_hrv": round(avg_hrv, 1), - "avg_rhr": round(avg_rhr, 1), - "hrv_vs_baseline_pct": hrv_baseline, - "rhr_vs_baseline_pct": rhr_baseline - }) - } + """HRV/RHR vs baseline (R2).""" + profile_id = session["profile_id"] + return build_hrv_rhr_baseline_chart_payload(profile_id, days) @router.get("/sleep-duration-quality") @@ -1782,107 +1414,9 @@ def get_sleep_duration_quality_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Sleep duration + quality (R3). - - Shows sleep duration and quality score over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js multi-line chart with sleep metrics - """ - profile_id = session['profile_id'] - - duration_data = get_sleep_duration_data(profile_id, days) - quality_data = get_sleep_quality_data(profile_id, days) - - if duration_data['confidence'] == 'insufficient': - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten vorhanden" - } - } - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, total_sleep_min - FROM sleep_log - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten" - } - } - - labels = [row['date'].isoformat() for row in rows] - duration_hours = [safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else None for row in rows] - - # Quality score (simple proxy: % of 8 hours) - quality_scores = [(d / 8 * 100) if d else None for d in duration_hours] - - datasets = [ - { - "label": "Schlafdauer (h)", - "data": duration_hours, - "borderColor": "#3B82F6", - "backgroundColor": "rgba(59, 130, 246, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y1", - "fill": True - }, - { - "label": "Qualität (%)", - "data": quality_scores, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y2", - "fill": False - } - ] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": datasets - }, - "metadata": serialize_dates({ - "confidence": duration_data['confidence'], - "data_points": len(rows), - "avg_duration_hours": round(duration_data['avg_duration_hours'], 1), - "sleep_quality_score": quality_data.get('sleep_quality_score', 0) - }) - } + """Sleep duration + quality (R3).""" + profile_id = session["profile_id"] + return build_sleep_duration_quality_chart_payload(profile_id, days) @router.get("/sleep-debt") @@ -1890,100 +1424,9 @@ def get_sleep_debt_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Sleep debt accumulation (R4). - - Shows cumulative sleep debt over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js line chart with sleep debt - """ - profile_id = session['profile_id'] - - current_debt = calculate_sleep_debt_hours(profile_id) - - if current_debt is None: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten für Schulden-Berechnung" - } - } - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, total_sleep_min - FROM sleep_log - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten" - } - } - - labels = [row['date'].isoformat() for row in rows] - - # Calculate cumulative debt (target 8h/night) - target_hours = 8.0 - cumulative_debt = 0 - debt_values = [] - - for row in rows: - actual_hours = safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else 0 - daily_deficit = target_hours - actual_hours - cumulative_debt += daily_deficit - debt_values.append(cumulative_debt) - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Schlafschuld (Stunden)", - "data": debt_values, - "borderColor": "#EF4444", - "backgroundColor": "rgba(239, 68, 68, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "current_debt_hours": round(current_debt, 1), - "final_debt_hours": round(cumulative_debt, 1) - }) - } + """Sleep debt (R4).""" + profile_id = session["profile_id"] + return build_sleep_debt_chart_payload(profile_id, days) @router.get("/vital-signs-matrix") @@ -1991,123 +1434,9 @@ def get_vital_signs_matrix_chart( days: int = Query(default=7, ge=7, le=30), session: dict = Depends(require_auth) ) -> Dict: - """ - Vital signs matrix (R5). - - Shows latest vital signs as horizontal bar chart. - - Args: - days: Max age of measurements (7-30 days, default 7) - session: Auth session (injected) - - Returns: - Chart.js horizontal bar chart with vital signs - """ - profile_id = session['profile_id'] - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - # Get latest vitals - cur.execute( - """SELECT resting_hr, hrv_ms, vo2_max, spo2, respiratory_rate - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date DESC - LIMIT 1""", - (profile_id, cutoff) - ) - vitals_row = cur.fetchone() - - # Get latest blood pressure - cur.execute( - """SELECT systolic, diastolic - FROM blood_pressure_log - WHERE profile_id=%s AND date >= %s - ORDER BY date DESC, time DESC - LIMIT 1""", - (profile_id, cutoff) - ) - bp_row = cur.fetchone() - - if not vitals_row and not bp_row: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine aktuellen Vitalwerte" - } - } - - labels = [] - values = [] - - if vitals_row: - if vitals_row['resting_hr']: - labels.append("Ruhepuls (bpm)") - values.append(safe_float(vitals_row['resting_hr'])) - if vitals_row['hrv_ms']: - labels.append("HRV (ms)") - values.append(safe_float(vitals_row['hrv_ms'])) - if vitals_row['vo2_max']: - labels.append("VO2 Max") - values.append(safe_float(vitals_row['vo2_max'])) - if vitals_row['spo2']: - labels.append("SpO2 (%)") - values.append(safe_float(vitals_row['spo2'])) - if vitals_row['respiratory_rate']: - labels.append("Atemfrequenz") - values.append(safe_float(vitals_row['respiratory_rate'])) - - if bp_row: - if bp_row['systolic']: - labels.append("Blutdruck sys (mmHg)") - values.append(safe_float(bp_row['systolic'])) - if bp_row['diastolic']: - labels.append("Blutdruck dia (mmHg)") - values.append(safe_float(bp_row['diastolic'])) - - if not labels: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Vitalwerte verfügbar" - } - } - - return { - "chart_type": "bar", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Wert", - "data": values, - "backgroundColor": "#1D9E75", - "borderColor": "#085041", - "borderWidth": 1 - } - ] - }, - "metadata": { - "confidence": "medium", - "data_points": len(values), - "note": "Latest measurements within last " + str(days) + " days" - } - } + """Vital signs matrix (R5).""" + profile_id = session["profile_id"] + return build_vital_signs_matrix_chart_payload(profile_id, days) # ── Correlation Charts ────────────────────────────────────────────────────── diff --git a/docs/README.md b/docs/README.md index d3c7aea..b33386b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ Dieser Ordner ist **immer mit Git versioniert**. Er ergänzt **`.claude/docs/`** | `issue-51-prompt-page-assignment.md` | | `issue-52-blood-pressure-dual-targets.md` | | `issue-53-phase-0c-multi-layer-architecture.md` | +| `issue-fitness-dashboard-layer2b.md` | | `issue-54-dynamic-placeholder-system.md` | | `issue-55-dynamic-aggregation-methods.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 diff --git a/docs/issues/issue-fitness-dashboard-layer2b.md b/docs/issues/issue-fitness-dashboard-layer2b.md new file mode 100644 index 0000000..70fdc05 --- /dev/null +++ b/docs/issues/issue-fitness-dashboard-layer2b.md @@ -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). diff --git a/frontend/src/components/FitnessDashboardOverview.jsx b/frontend/src/components/FitnessDashboardOverview.jsx new file mode 100644 index 0000000..447a4b2 --- /dev/null +++ b/frontend/src/components/FitnessDashboardOverview.jsx @@ -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 ( +
+ Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier. +
+ ++ Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. {eff} Tage · Volumen{' '} + {wUsed} Wochen · Kategorien {dTyp} Tage · Load-Zeitreihe{' '} + {loadDays ?? '—'} Tage + {viz.last_updated ? ( + <> + {' '} + · letzte Aktivität {viz.last_updated} + > + ) : null} + . +
+ ++ {viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst + oder importierst, erscheinen Auswertungen hier. +
+ ++ Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. {eff} Tage · Charts{' '} + {cDays} Tage · Vital-Matrix {vDays} Tage. +
+ +- Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Aktivität. + Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Fitness.
{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 }) { const [period, setPeriod] = useState(30) - if (!activities?.length) return ( -+ Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig. +
+