diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index fe86308..ee78809 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -501,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, @@ -518,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() @@ -560,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]: @@ -1353,3 +1362,176 @@ def build_training_type_distribution_chart_payload(profile_id: str, days: int) - "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 index 1e92d12..114ec36 100644 --- a/backend/data_layer/fitness_interpretation.py +++ b/backend/data_layer/fitness_interpretation.py @@ -57,6 +57,87 @@ def _vo2_status(trend: Optional[float]) -> str: 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], @@ -65,6 +146,7 @@ def build_fitness_dashboard_kpi_tiles( 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: @@ -78,68 +160,102 @@ def build_fitness_dashboard_kpi_tiles( s_status = _score_status(activity_score) v_status = _vo2_status(vo2_trend) - tiles: List[Dict[str, Any]] = [ - { - "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"], - }, - ] + 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") diff --git a/backend/data_layer/fitness_viz.py b/backend/data_layer/fitness_viz.py index 18c9a11..9c8ae03 100644 --- a/backend/data_layer/fitness_viz.py +++ b/backend/data_layer/fitness_viz.py @@ -10,6 +10,8 @@ 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, @@ -17,8 +19,12 @@ from data_layer.activity_metrics import ( 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.fitness_interpretation import build_fitness_dashboard_kpi_tiles from data_layer.scores import get_top_focus_area @@ -66,6 +72,8 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An "message": "Noch keine Aktivitätsdaten", "kpi_tiles": [], "summary": {}, + "progress_insights": [], + "volume_delta": {}, "charts": {}, "meta": {"layer_1": "activity_metrics", "layer_2b": "fitness_viz"}, } @@ -77,9 +85,12 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An 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) @@ -87,6 +98,7 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An 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, @@ -96,8 +108,14 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An 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" @@ -113,10 +131,15 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An "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", diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 96425cd..80229dc 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -46,13 +46,13 @@ from data_layer.nutrition_metrics import ( from data_layer.activity_metrics import ( get_activity_summary_data, calculate_training_minutes_week, - calculate_quality_sessions_pct, - calculate_proxy_internal_load_7d, calculate_monotony_score, calculate_strain_score, 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, @@ -1115,63 +1115,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") @@ -1192,74 +1136,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") diff --git a/docs/issues/issue-fitness-dashboard-layer2b.md b/docs/issues/issue-fitness-dashboard-layer2b.md index d18ce8f..70fdc05 100644 --- a/docs/issues/issue-fitness-dashboard-layer2b.md +++ b/docs/issues/issue-fitness-dashboard-layer2b.md @@ -42,13 +42,13 @@ ## Erweiterungen (optional) -- Weitere Charts aus A3–A8 ins Bundle ziehen (weiterhin nur Payload-Referenz, keine Duplikat-Logik im Router). -- Gitea-Issue anlegen/verknüpfen, falls formale Nachverfolgung gewünscht. +- Weitere Charts aus A5–A8 ins Bundle (Monotonie, Fähigkeiten …), gleiches Muster: Builder in `activity_metrics`, Router nur delegieren. --- ## Abnahme-Checkliste -- [x] Bundle liefert `has_activity_entries`, `summary`, `kpi_tiles`, `charts.training_volume`, `charts.training_type_distribution`, `meta`. -- [x] Keine clientseitige Neuberechnung der KPIs aus Rohlisten. -- [x] `/api/charts/training-volume` und `/training-type-distribution` nutzen dieselben Builder wie das Bundle. +- [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 index b321d87..447a4b2 100644 --- a/frontend/src/components/FitnessDashboardOverview.jsx +++ b/frontend/src/components/FitnessDashboardOverview.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' import { BarChart, Bar, @@ -9,9 +10,14 @@ import { 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' }, @@ -22,16 +28,13 @@ const PERIODS = [ /** * Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics). - * - * @param {number} [period] – gesteuert von außen (z. B. Verlauf `PeriodSelector`); mit `onPeriodChange` koppeln. - * @param {(n: number) => void} [onPeriodChange] - * @param {boolean} [hidePeriodSelector] – eigenes Zeitraum-Dropdown ausblenden (wenn die Seite oben schon einen Zeitraum wählt). */ 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 @@ -82,16 +85,21 @@ export default function FitnessDashboardOverview({ return (
- Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Kennzahlen und - Diagramme hier. +
+ Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.
+
- Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Aktivitäts-Data-Layer). Zusammenfassung
- ca. {eff} Tage · Volumen-Chart {wUsed} Wochen · Typ-Verteilung{' '}
- {dTyp} Tage
+ 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 ? (
<>
{' '}
@@ -157,10 +184,33 @@ export default function FitnessDashboardOverview({
- Fitness-Kennzahlen und Diagramme (Layer 2b) kommen aus dem Aktivitäts-Data-Layer — dieselbe Quelle wie die - KI-Platzhalter. Zeitraum gilt auch für die Liste unten. + Auswertung ausschließlich aus dem Fitness-Bundle (Data-Layer / Issue 53). Zeitraum-Buttons steuern dasselbe + Fenster wie die API.