From b5c5f2f6126f2f0dbfcc926493107f322d7f765a Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 21:27:12 +0200 Subject: [PATCH 1/6] feat: introduce fitness dashboard overview and enhance activity metrics - Added new functions to build fitness dashboard visualizations, including weekly training volume and training type distribution charts. - Updated the `charts.py` router to include a new endpoint for the fitness dashboard, integrating data from activity metrics. - Refactored existing activity-related functions to improve modularity and maintainability. - Updated frontend components to reflect the new fitness terminology and integrate the fitness dashboard overview, enhancing user experience. --- backend/data_layer/activity_metrics.py | 145 +++++++++++- backend/data_layer/fitness_interpretation.py | 167 ++++++++++++++ backend/data_layer/fitness_viz.py | 125 +++++++++++ backend/routers/charts.py | 134 +++-------- docs/README.md | 3 +- .../issues/issue-fitness-dashboard-layer2b.md | 54 +++++ .../components/FitnessDashboardOverview.jsx | 211 ++++++++++++++++++ frontend/src/config/captureNav.js | 2 +- frontend/src/pages/ActivityPage.jsx | 8 +- frontend/src/pages/History.jsx | 12 +- frontend/src/utils/api.js | 2 + 11 files changed, 739 insertions(+), 124 deletions(-) create mode 100644 backend/data_layer/fitness_interpretation.py create mode 100644 backend/data_layer/fitness_viz.py create mode 100644 docs/issues/issue-fitness-dashboard-layer2b.md create mode 100644 frontend/src/components/FitnessDashboardOverview.jsx diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index ebb1731..fe86308 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) @@ -1222,3 +1228,128 @@ 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"], + }, + } diff --git a/backend/data_layer/fitness_interpretation.py b/backend/data_layer/fitness_interpretation.py new file mode 100644 index 0000000..1e92d12 --- /dev/null +++ b/backend/data_layer/fitness_interpretation.py @@ -0,0 +1,167 @@ +""" +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 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]], +) -> 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]] = [ + { + "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..18c9a11 --- /dev/null +++ b/backend/data_layer/fitness_viz.py @@ -0,0 +1,125 @@ +""" +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_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, +) +from data_layer.fitness_interpretation import build_fitness_dashboard_kpi_tiles +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": {}, + "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))) + + volume_chart = build_training_volume_chart_payload(profile_id, weeks_vol) + type_chart = build_training_type_distribution_chart_payload(profile_id, dist_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) + + kpi_tiles = build_fitness_dashboard_kpi_tiles( + summary, + minutes_7d, + quality_pct, + quality_days, + activity_score, + vo2_trend, + top_focus, + ) + + 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": [], + "charts": { + "training_volume": volume_chart, + "training_type_distribution": type_chart, + }, + "meta": { + "layer_1": "activity_metrics", + "layer_2b": "fitness_viz", + "issue": "53-layer-2b-fitness", + }, + } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index fca7c36..96425cd 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -33,6 +33,7 @@ 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.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, @@ -44,13 +45,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, ) from data_layer.recovery_metrics import ( get_sleep_duration_data, @@ -288,6 +290,26 @@ 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("/circumferences") def get_circumferences_chart( max_age_days: int = Query(default=90, ge=7, le=365), @@ -1051,66 +1073,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 +1094,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") 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..57dbb3f --- /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 `/activity` (Capture-Hub: „Fitness“), die **keine parallelen Berechnungen** im Client führt. +- **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/ActivityPage.jsx` (oben, vor Tabs) | +| Navigation Capture | `frontend/src/config/captureNav.js` – Label **Fitness**, Route `/activity` | + +--- + +## 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. + +--- + +## 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. diff --git a/frontend/src/components/FitnessDashboardOverview.jsx b/frontend/src/components/FitnessDashboardOverview.jsx new file mode 100644 index 0000000..227479f --- /dev/null +++ b/frontend/src/components/FitnessDashboardOverview.jsx @@ -0,0 +1,211 @@ +import { useState, useEffect } from 'react' +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + PieChart, + Pie, +} from 'recharts' +import { api } from '../utils/api' +import KpiTilesOverview from './KpiTilesOverview' + +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() { + const [period, setPeriod] = useState(28) + 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 ( +
+
Fitness-Übersicht
+
+
+ ) + } + + if (err) { + return ( +
+
Fitness-Übersicht
+
{err}
+
+ ) + } + + if (!viz?.has_activity_entries) { + return ( +
+
Fitness-Übersicht
+

+ Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Kennzahlen und + Diagramme hier. +

+
+ ) + } + + const vol = viz.charts?.training_volume + const typ = viz.charts?.training_type_distribution + const volRows = (vol?.data?.labels || []).map((name, i) => ({ + name, + min: vol?.data?.datasets?.[0]?.data?.[i] ?? 0, + })) + const pieLabels = typ?.data?.labels || [] + const pieVals = typ?.data?.datasets?.[0]?.data || [] + const pieColors = typ?.data?.datasets?.[0]?.backgroundColor || [] + const pieData = pieLabels.map((name, i) => ({ + name, + value: pieVals[i], + fill: pieColors[i] || '#888780', + })) + + const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ + ...t, + sublabel: + typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, + })) + + const eff = viz.effective_window_days + const wUsed = viz.training_volume_weeks_used + const dTyp = viz.training_type_dist_days_used + + return ( +
+
+ Fitness-Übersicht + +
+ +

+ 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 + {viz.last_updated ? ( + <> + {' '} + · letzte Aktivität {viz.last_updated} + + ) : null} + . +

+ + + +
+
+
+ Trainingsvolumen (Minuten / Woche) +
+ {volRows.length >= 1 ? ( + + + + + + [`${Math.round(v)} min`, 'Volumen']} + /> + + + + ) : ( +
Keine Wochendaten im gewählten Fenster.
+ )} +
+ +
+
+ Training nach Kategorie +
+ {pieData.length >= 1 ? ( + + + `${name} ${(percent * 100).toFixed(0)}%`} + /> + + + + ) : ( +
Keine kategorisierten Sessions im Fenster.
+ )} +
+
+
+ ) +} diff --git a/frontend/src/config/captureNav.js b/frontend/src/config/captureNav.js index 29bab26..64314da 100644 --- a/frontend/src/config/captureNav.js +++ b/frontend/src/config/captureNav.js @@ -56,7 +56,7 @@ export const CAPTURE_HUB_TILES = [ }, { icon: '🏋️', - label: 'Aktivität', + label: 'Fitness', sub: 'Training manuell oder Apple Health importieren', to: '/activity', color: '#D4537E', diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 45a872c..85d05ab 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -3,6 +3,7 @@ import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' import UsageBadge from '../components/UsageBadge' +import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import TrainingTypeSelect from '../components/TrainingTypeSelect' import BulkCategorize from '../components/BulkCategorize' import dayjs from 'dayjs' @@ -912,7 +913,12 @@ export default function ActivityPage() { return (
-

Aktivität

+

Fitness

+

+ Auswertung (Data-Layer) und Erfassung an einem Ort. +

+ +
diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index c6f4591..2940aec 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -328,10 +328,10 @@ function InsightBox({ insights, slugs, onRequest, loading }) { const [expanded, setExpanded] = useState(null) const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung', - aktivitaet:'Aktivität',gesundheit:'Gesundheit',ziele:'Ziele', + aktivitaet:'Fitness',gesundheit:'Gesundheit',ziele:'Ziele', pipeline:'🔬 Mehrstufige Analyse', pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung', - pipeline_activity:'Pipeline Aktivität',pipeline_synthesis:'Pipeline Synthese', + pipeline_activity:'Pipeline Fitness',pipeline_synthesis:'Pipeline Synthese', pipeline_goals:'Pipeline Ziele'} return (
@@ -535,7 +535,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl

- 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 && ( @@ -1101,7 +1101,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { const [period, setPeriod] = useState(30) if (!activities?.length) return ( - + ) const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') @@ -1132,7 +1132,7 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA return (
- + {/* Issue #31: Show active global quality filter */} @@ -1518,7 +1518,7 @@ function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs } const TABS = [ { id:'body', label:'⚖️ Körper' }, { id:'nutrition', label:'🍽️ Ernährung' }, - { id:'activity', label:'🏋️ Aktivität' }, + { id:'activity', label:'🏋️ Fitness' }, { id:'recovery', label:'😴 Erholung' }, { id:'correlation', label:'🔗 Korrelation' }, { id:'photos', label:'📷 Fotos' }, diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index c55e583..9185df7 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -639,6 +639,8 @@ export const api = { getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`), /** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */ getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`), + /** Layer 2b: Fitness-Übersicht — KPI + Volumen/Typ-Charts (activity_metrics) */ + getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`), getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`), From 22c5f695c9251818a09990bedae5e2164f2aa9b5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 21:37:12 +0200 Subject: [PATCH 2/6] refactor: update fitness dashboard integration and terminology - Changed the fitness overview path from `/activity` to `/history` and updated related navigation labels to reflect this change. - Refactored the `FitnessDashboardOverview` component to accept external period control and conditionally display the period selector. - Integrated the `FitnessDashboardOverview` into the `History` page, enhancing the user experience with consistent terminology and layout. - Removed the fitness overview from the `ActivityPage` to streamline the interface and focus on activity data collection. --- .../issues/issue-fitness-dashboard-layer2b.md | 6 +-- .../components/FitnessDashboardOverview.jsx | 49 +++++++++++++------ frontend/src/config/captureNav.js | 2 +- frontend/src/pages/ActivityPage.jsx | 8 +-- frontend/src/pages/History.jsx | 30 +++++++++--- 5 files changed, 60 insertions(+), 35 deletions(-) diff --git a/docs/issues/issue-fitness-dashboard-layer2b.md b/docs/issues/issue-fitness-dashboard-layer2b.md index 57dbb3f..d18ce8f 100644 --- a/docs/issues/issue-fitness-dashboard-layer2b.md +++ b/docs/issues/issue-fitness-dashboard-layer2b.md @@ -8,7 +8,7 @@ ## Ziel -- Eine **Fitness-Übersicht** auf `/activity` (Capture-Hub: „Fitness“), die **keine parallelen Berechnungen** im Client führt. +- 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. --- @@ -35,8 +35,8 @@ |-------------|------| | API-Client | `getFitnessDashboardViz(days)` in `frontend/src/utils/api.js` | | Darstellung | `frontend/src/components/FitnessDashboardOverview.jsx` | -| Einbindung | `frontend/src/pages/ActivityPage.jsx` (oben, vor Tabs) | -| Navigation Capture | `frontend/src/config/captureNav.js` – Label **Fitness**, Route `/activity` | +| Einbindung | `frontend/src/pages/History.jsx` → `ActivitySection` (gemeinsamer `PeriodSelector` wie die Liste darunter) | +| Erfassung | `/activity` bleibt reine Erfassung; Capture-Hub-Label **Aktivität** | --- diff --git a/frontend/src/components/FitnessDashboardOverview.jsx b/frontend/src/components/FitnessDashboardOverview.jsx index 227479f..b321d87 100644 --- a/frontend/src/components/FitnessDashboardOverview.jsx +++ b/frontend/src/components/FitnessDashboardOverview.jsx @@ -22,9 +22,20 @@ 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() { - const [period, setPeriod] = useState(28) +export default function FitnessDashboardOverview({ + period: periodProp, + onPeriodChange, + hidePeriodSelector = false, +}) { + 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) @@ -104,25 +115,31 @@ export default function FitnessDashboardOverview() { const wUsed = viz.training_volume_weeks_used const dTyp = viz.training_type_dist_days_used + const showPeriodDropdown = !hidePeriodSelector && !controlled + return (
Fitness-Übersicht - + Zeitraum + + + ) : null}

diff --git a/frontend/src/config/captureNav.js b/frontend/src/config/captureNav.js index 64314da..29bab26 100644 --- a/frontend/src/config/captureNav.js +++ b/frontend/src/config/captureNav.js @@ -56,7 +56,7 @@ export const CAPTURE_HUB_TILES = [ }, { icon: '🏋️', - label: 'Fitness', + label: 'Aktivität', sub: 'Training manuell oder Apple Health importieren', to: '/activity', color: '#D4537E', diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 85d05ab..45a872c 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -3,7 +3,6 @@ import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' import UsageBadge from '../components/UsageBadge' -import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import TrainingTypeSelect from '../components/TrainingTypeSelect' import BulkCategorize from '../components/BulkCategorize' import dayjs from 'dayjs' @@ -913,12 +912,7 @@ export default function ActivityPage() { return (

-

Fitness

-

- Auswertung (Data-Layer) und Erfassung an einem Ort. -

- - +

Aktivität

diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 2940aec..be6c619 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -14,6 +14,7 @@ import { getStatusColor, getStatusBg } from '../utils/interpret' import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' +import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' import RecoveryCharts from '../components/RecoveryCharts' import KpiTilesOverview from '../components/KpiTilesOverview' @@ -1097,16 +1098,14 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct ) } -// ── Activity Section ────────────────────────────────────────────────────────── +// ── Activity Section — Layer 2b Fitness-Bundle wie Körper/Ernährung auf /history ─ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { const [period, setPeriod] = useState(30) - if (!activities?.length) return ( - - ) const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') // Issue #31: Backend already filters by global quality level - only filter by period here - const filtA = activities.filter(d => period === 9999 || d.date >= cutoff) + const actList = activities || [] + const filtA = actList.filter(d => period === 9999 || d.date >= cutoff) const byDate={} filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) }) @@ -1130,13 +1129,24 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA value:consistency+'%' }] + const hasList = actList.length > 0 + return (
- + +

+ 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. +

+ + + {!hasList ? ( + + ) : null} {/* Issue #31: Show active global quality filter */} - {globalQualityLevel && globalQualityLevel !== 'all' && ( + {hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
)} + {!hasList ? null : ( + <>
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'], ['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'], @@ -1186,7 +1198,7 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
{type}
{count}×
-
+
))}
@@ -1199,6 +1211,8 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA {actRules.map((item,i)=>)}
+ + )}
) } From bf84e3c2a5077a3b731b96560a953fd314f520a9 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 08:04:50 +0200 Subject: [PATCH 3/6] feat: enhance fitness dashboard with new metrics and insights - Refactored the `calculate_proxy_internal_load_7d` function to `calculate_proxy_internal_load_window`, allowing for dynamic day range input. - Introduced new functions for calculating training volume deltas and building fitness progress insights, enhancing user feedback on training metrics. - Updated the fitness dashboard to include new charts for quality sessions and load monitoring, improving data visualization. - Integrated these new metrics into the fitness dashboard overview, providing users with comprehensive insights into their training performance. - Streamlined the router to utilize the new chart-building functions, ensuring consistency and maintainability across the application. --- backend/data_layer/activity_metrics.py | 196 +++++++++++++- backend/data_layer/fitness_interpretation.py | 240 +++++++++++++----- backend/data_layer/fitness_viz.py | 25 +- backend/routers/charts.py | 131 +--------- .../issues/issue-fitness-dashboard-layer2b.md | 10 +- .../components/FitnessDashboardOverview.jsx | 144 ++++++++++- frontend/src/pages/History.jsx | 92 +------ 7 files changed, 540 insertions(+), 298 deletions(-) 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 (
Fitness-Übersicht
-

- 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.

+
) } const vol = viz.charts?.training_volume const typ = viz.charts?.training_type_distribution + const qual = viz.charts?.quality_sessions + const loadCh = viz.charts?.load_monitoring + const volRows = (vol?.data?.labels || []).map((name, i) => ({ name, min: vol?.data?.datasets?.[0]?.data?.[i] ?? 0, @@ -105,15 +113,34 @@ export default function FitnessDashboardOverview({ fill: pieColors[i] || '#888780', })) + const qualLabels = qual?.data?.labels || [] + const qualVals = qual?.data?.datasets?.[0]?.data || [] + const qualBg = qual?.data?.datasets?.[0]?.backgroundColor || [] + const qualBar = qualLabels.map((name, i) => ({ + name, + n: qualVals[i] ?? 0, + fill: qualBg[i] || '#1D9E75', + })) + + const loadLabels = loadCh?.data?.labels || [] + const loadVals = loadCh?.data?.datasets?.[0]?.data || [] + const loadRows = loadLabels.map((iso, i) => ({ + t: dayjs(iso).format('DD.MM.'), + load: loadVals[i] ?? 0, + })) + const loadMeta = loadCh?.metadata || {} + const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ ...t, sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, })) + const insights = viz.progress_insights || [] const eff = viz.effective_window_days const wUsed = viz.training_volume_weeks_used const dTyp = viz.training_type_dist_days_used + const loadDays = viz.load_chart_days_used const showPeriodDropdown = !hidePeriodSelector && !controlled @@ -143,9 +170,9 @@ export default function FitnessDashboardOverview({

- 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({ + {insights.length > 0 ? ( +

+
Einschätzungen
+
+ {insights.map((ins) => ( +
+
{ins.title}
+
{ins.body}
+
+ ))} +
+
+ ) : null} +
- + Keine kategorisierten Sessions im Fenster.
)}
+ +
+
+ Qualitäts-Sessions (Schätzung) +
+ {qualBar.length >= 1 ? ( + + + + + + + + {qualBar.map((entry, i) => ( + + ))} + + + + ) : ( +
Keine Daten.
+ )} +
+ +
+
+ Belastung (Proxy-Load · duration×RPE / Tag) +
+ {loadRows.length >= 1 ? ( + <> + + + + + + + + + +
+ ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} ( + {loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy) +
+ + ) : ( +
Keine Load-Daten im Fenster.
+ )} +
) diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index be6c619..2a78d14 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -13,7 +13,6 @@ import { getBfCategory } from '../utils/calc' import { getStatusColor, getStatusBg } from '../utils/interpret' import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' -import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' import RecoveryCharts from '../components/RecoveryCharts' @@ -1098,37 +1097,10 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct ) } -// ── Activity Section — Layer 2b Fitness-Bundle wie Körper/Ernährung auf /history ─ +// ── 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) - const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') - - // Issue #31: Backend already filters by global quality level - only filter by period here const actList = activities || [] - const filtA = actList.filter(d => period === 9999 || d.date >= cutoff) - - const byDate={} - filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) }) - const cd=Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).map(([date,kcal])=>({date:fmtDate(date),kcal:Math.round(kcal)})) - - const totalKcal=Math.round(filtA.reduce((s,a)=>s+(a.kcal_active||0),0)) - const totalMin =Math.round(filtA.reduce((s,a)=>s+(a.duration_min||0),0)) - const hrData =filtA.filter(a=>a.hr_avg) - const avgHr =hrData.length?Math.round(hrData.reduce((s,a)=>s+a.hr_avg,0)/hrData.length):null - const types={}; filtA.forEach(a=>{ types[a.activity_type]=(types[a.activity_type]||0)+1 }) - const topTypes=Object.entries(types).sort((a,b)=>b[1]-a[1]) - - const daysWithAct=new Set(filtA.map(a=>a.date)).size - const totalDays=Math.min(period,dayjs().diff(dayjs(filtA[filtA.length-1]?.date),'day')+1) - const consistency=totalDays>0?Math.round(daysWithAct/totalDays*100):0 - const actRules=[{ - status:consistency>=70?'good':consistency>=40?'warn':'bad', - icon:'📅', category:'Konsistenz', - title:`${consistency}% aktive Tage (${daysWithAct}/${Math.min(period,30)} Tage)`, - detail:consistency>=70?'Ausgezeichnete Regelmäßigkeit.':consistency>=40?'Ziel: 4–5 Einheiten/Woche.':'Mehr Regelmäßigkeit empfohlen.', - value:consistency+'%' - }] - const hasList = actList.length > 0 return ( @@ -1136,19 +1108,15 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA

- 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.

- {!hasList ? ( - - ) : null} - - {/* Issue #31: Show active global quality filter */} {hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
@@ -1166,53 +1134,9 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
)} - {!hasList ? null : ( - <> -
- {[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'], - ['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'], - avgHr?['Ø HF',avgHr+' bpm','#D85A30']:null].filter(Boolean).map(([l,v,c])=>( -
-
{v}
-
{l}
-
- ))} -
-
-
Aktive Kalorien / Tag
- - - - - - [`${v} kcal`]}/> - - - -
-
-
Trainingsarten
- {topTypes.map(([type,count])=>( -
-
{type}
-
{count}×
-
-
- ))} -
-
-
Trainingstyp-Verteilung
- -
-
-
BEWERTUNG
- {actRules.map((item,i)=>)} -
- - - )} + {hasList ? ( + + ) : null}
) } From f42d3a9c92420b667e7b0c5abd92047d57f700f3 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 08:11:23 +0200 Subject: [PATCH 4/6] feat: introduce recovery dashboard visualization and refactor recovery charts - Added a new endpoint for the recovery dashboard visualization in `charts.py`, integrating multiple recovery metrics and insights. - Implemented the `get_recovery_dashboard_viz` function to streamline data retrieval for recovery-related charts. - Refactored the `RecoveryCharts` component to utilize the new `RecoveryDashboardOverview`, simplifying the component structure and enhancing maintainability. - Updated the `RecoveryChartsPanelWidget` and `History` page to reflect the new recovery dashboard, improving user navigation and experience. - Deprecated the old recovery charts component, encouraging the use of the new overview for better data presentation. --- backend/data_layer/recovery_chart_payloads.py | 454 +++++++++++++++ backend/data_layer/recovery_interpretation.py | 183 ++++++ backend/data_layer/recovery_viz.py | 111 ++++ backend/routers/charts.py | 548 ++---------------- frontend/src/components/RecoveryCharts.jsx | 318 +--------- .../components/RecoveryDashboardOverview.jsx | 402 +++++++++++++ .../RecoveryChartsPanelWidget.jsx | 12 +- frontend/src/pages/History.jsx | 46 +- frontend/src/utils/api.js | 2 + 9 files changed, 1219 insertions(+), 857 deletions(-) create mode 100644 backend/data_layer/recovery_chart_payloads.py create mode 100644 backend/data_layer/recovery_interpretation.py create mode 100644 backend/data_layer/recovery_viz.py create mode 100644 frontend/src/components/RecoveryDashboardOverview.jsx diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py new file mode 100644 index 0000000..20c87f1 --- /dev/null +++ b/backend/data_layer/recovery_chart_payloads.py @@ -0,0 +1,454 @@ +""" +Chart.js-Payloads für Recovery (R1–R5) — gemeinsam mit routers/charts und recovery-dashboard-viz. + +Ausgelagert aus routers/charts.py (Issue 53 / Layer 1). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Dict + +from db import get_db, get_cursor +from data_layer.recovery_metrics import ( + calculate_hrv_vs_baseline_pct, + calculate_recovery_score_v2, + calculate_rhr_vs_baseline_pct, + calculate_sleep_debt_hours, + get_sleep_duration_data, + get_sleep_quality_data, +) +from data_layer.utils import calculate_confidence, safe_float, serialize_dates + + +def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + current_score = calculate_recovery_score_v2(profile_id) + + if current_score is None: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Recovery-Daten vorhanden", + }, + } + + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, resting_hr, hrv + FROM vitals_baseline + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": { + "labels": [datetime.now().strftime("%Y-%m-%d")], + "datasets": [ + { + "label": "Recovery Score", + "data": [current_score], + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "fill": True, + } + ], + }, + "metadata": { + "confidence": "low", + "data_points": 1, + "current_score": current_score, + }, + } + + labels = [row["date"].isoformat() for row in rows] + values = [min(100, max(0, safe_float(row["hrv"]) if row["hrv"] else 50)) for row in rows] + + return { + "chart_type": "line", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Recovery Score (proxy)", + "data": values, + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "fill": True, + } + ], + }, + "metadata": serialize_dates( + { + "confidence": calculate_confidence(len(rows), days, "general"), + "data_points": len(rows), + "current_score": current_score, + "note": "Score based on HRV proxy; true recovery score calculation in development", + } + ), + } + + +def build_hrv_rhr_baseline_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, resting_hr, hrv + FROM vitals_baseline + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Vitalwerte vorhanden", + }, + } + + labels = [row["date"].isoformat() for row in rows] + hrv_values = [safe_float(row["hrv"]) if row["hrv"] else None for row in rows] + rhr_values = [safe_float(row["resting_hr"]) if row["resting_hr"] else None for row in rows] + + hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id) + rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id) + + hrv_filtered = [v for v in hrv_values if v is not None] + rhr_filtered = [v for v in rhr_values if v is not None] + + avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50 + avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60 + + datasets = [ + { + "label": "HRV (ms)", + "data": hrv_values, + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y1", + "fill": False, + }, + { + "label": "RHR (bpm)", + "data": rhr_values, + "borderColor": "#3B82F6", + "backgroundColor": "rgba(59, 130, 246, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y2", + "fill": False, + }, + ] + + return { + "chart_type": "line", + "data": {"labels": labels, "datasets": datasets}, + "metadata": serialize_dates( + { + "confidence": calculate_confidence(len(rows), days, "general"), + "data_points": len(rows), + "avg_hrv": round(avg_hrv, 1), + "avg_rhr": round(avg_rhr, 1), + "hrv_vs_baseline_pct": hrv_baseline, + "rhr_vs_baseline_pct": rhr_baseline, + } + ), + } + + +def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + duration_data = get_sleep_duration_data(profile_id, days) + quality_data = get_sleep_quality_data(profile_id, days) + + if duration_data["confidence"] == "insufficient": + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten vorhanden", + }, + } + + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, total_sleep_min + FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten", + }, + } + + labels = [row["date"].isoformat() for row in rows] + duration_hours = [safe_float(row["total_sleep_min"]) / 60 if row["total_sleep_min"] else None for row in rows] + + quality_scores = [(d / 8 * 100) if d else None for d in duration_hours] + + datasets = [ + { + "label": "Schlafdauer (h)", + "data": duration_hours, + "borderColor": "#3B82F6", + "backgroundColor": "rgba(59, 130, 246, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y1", + "fill": True, + }, + { + "label": "Qualität (%)", + "data": quality_scores, + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "yAxisID": "y2", + "fill": False, + }, + ] + + return { + "chart_type": "line", + "data": {"labels": labels, "datasets": datasets}, + "metadata": serialize_dates( + { + "confidence": duration_data["confidence"], + "data_points": len(rows), + "avg_duration_hours": round(duration_data["avg_duration_hours"], 1), + "sleep_quality_score": quality_data.get("sleep_quality_score", 0), + } + ), + } + + +def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 90: + days = 90 + current_debt = calculate_sleep_debt_hours(profile_id) + + if current_debt is None: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten für Schulden-Berechnung", + }, + } + + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, total_sleep_min + FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows: + return { + "chart_type": "line", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Schlafdaten", + }, + } + + labels = [row["date"].isoformat() for row in rows] + + target_hours = 8.0 + cumulative_debt = 0.0 + debt_values = [] + + for row in rows: + actual_hours = safe_float(row["total_sleep_min"]) / 60 if row["total_sleep_min"] else 0 + daily_deficit = target_hours - actual_hours + cumulative_debt += daily_deficit + debt_values.append(cumulative_debt) + + return { + "chart_type": "line", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Schlafschuld (Stunden)", + "data": debt_values, + "borderColor": "#EF4444", + "backgroundColor": "rgba(239, 68, 68, 0.1)", + "borderWidth": 2, + "tension": 0.3, + "fill": True, + } + ], + }, + "metadata": serialize_dates( + { + "confidence": calculate_confidence(len(rows), days, "general"), + "data_points": len(rows), + "current_debt_hours": round(float(current_debt), 1), + "final_debt_hours": round(float(cumulative_debt), 1), + } + ), + } + + +def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[str, Any]: + if days < 7: + days = 7 + if days > 30: + days = 30 + cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT resting_hr, hrv, vo2_max, spo2, respiratory_rate + FROM vitals_baseline + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC + LIMIT 1""", + (profile_id, cutoff), + ) + vitals_row = cur.fetchone() + + cur.execute( + """SELECT systolic, diastolic + FROM blood_pressure_log + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC, time DESC + LIMIT 1""", + (profile_id, cutoff), + ) + bp_row = cur.fetchone() + + if not vitals_row and not bp_row: + return { + "chart_type": "bar", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine aktuellen Vitalwerte", + }, + } + + labels = [] + values = [] + + if vitals_row: + if vitals_row["resting_hr"]: + labels.append("Ruhepuls (bpm)") + values.append(safe_float(vitals_row["resting_hr"])) + if vitals_row["hrv"]: + labels.append("HRV (ms)") + values.append(safe_float(vitals_row["hrv"])) + if vitals_row["vo2_max"]: + labels.append("VO2 Max") + values.append(safe_float(vitals_row["vo2_max"])) + if vitals_row["spo2"]: + labels.append("SpO2 (%)") + values.append(safe_float(vitals_row["spo2"])) + if vitals_row["respiratory_rate"]: + labels.append("Atemfrequenz") + values.append(safe_float(vitals_row["respiratory_rate"])) + + if bp_row: + if bp_row["systolic"]: + labels.append("Blutdruck sys (mmHg)") + values.append(safe_float(bp_row["systolic"])) + if bp_row["diastolic"]: + labels.append("Blutdruck dia (mmHg)") + values.append(safe_float(bp_row["diastolic"])) + + if not labels: + return { + "chart_type": "bar", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Vitalwerte verfügbar", + }, + } + + return { + "chart_type": "bar", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Wert", + "data": values, + "backgroundColor": "#1D9E75", + "borderColor": "#085041", + "borderWidth": 1, + } + ], + }, + "metadata": { + "confidence": "medium", + "data_points": len(values), + "note": "Latest measurements within last " + str(days) + " days", + }, + } 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 80229dc..508cff0 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -34,6 +34,14 @@ from data_layer.body_metrics import ( from data_layer.body_viz import get_body_history_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle +from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle +from data_layer.recovery_chart_payloads import ( + build_recovery_score_chart_payload, + build_hrv_rhr_baseline_chart_payload, + build_sleep_duration_quality_chart_payload, + build_sleep_debt_chart_payload, + build_vital_signs_matrix_chart_payload, +) from data_layer.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, @@ -310,6 +318,24 @@ def get_fitness_dashboard_viz( return serialize_dates(bundle) +@router.get("/recovery-dashboard-viz") +def get_recovery_dashboard_viz( + days: int = Query( + default=28, + ge=7, + le=9999, + description="Analysefenster in Tagen (9999 = lange Historie)", + ), + session: dict = Depends(require_auth), +) -> Dict: + """ + Layer 2b: Recovery/Erholung — KPIs, Insights, Charts R1–R5 (recovery_metrics). + """ + profile_id = session["profile_id"] + bundle = get_recovery_dashboard_viz_bundle(profile_id, days) + return serialize_dates(bundle) + + @router.get("/circumferences") def get_circumferences_chart( max_age_days: int = Query(default=90, ge=7, le=365), @@ -1368,106 +1394,9 @@ def get_recovery_score_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Recovery score timeline (R1). - - Shows daily recovery scores over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js line chart with recovery scores - """ - profile_id = session['profile_id'] - - # For PoC: Use current recovery score and create synthetic timeline - # TODO: Store historical recovery scores for true timeline - current_score = calculate_recovery_score_v2(profile_id) - - if current_score is None: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Recovery-Daten vorhanden" - } - } - - # Fetch vitals for timeline approximation - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, resting_hr, hrv_ms - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [datetime.now().strftime('%Y-%m-%d')], - "datasets": [ - { - "label": "Recovery Score", - "data": [current_score], - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": { - "confidence": "low", - "data_points": 1, - "current_score": current_score - } - } - - # Simple proxy: Use HRV as recovery indicator (higher HRV = better recovery) - # This is a placeholder until we store actual recovery scores - labels = [row['date'].isoformat() for row in rows] - # Normalize HRV to 0-100 scale (assume typical range 20-100ms) - values = [min(100, max(0, safe_float(row['hrv_ms']) if row['hrv_ms'] else 50)) for row in rows] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Recovery Score (proxy)", - "data": values, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "current_score": current_score, - "note": "Score based on HRV proxy; true recovery score calculation in development" - }) - } + """Recovery score timeline (R1). Delegiert an recovery_chart_payloads.""" + profile_id = session["profile_id"] + return build_recovery_score_chart_payload(profile_id, days) @router.get("/hrv-rhr-baseline") @@ -1475,101 +1404,9 @@ def get_hrv_rhr_baseline_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - HRV/RHR vs baseline (R2). - - Shows HRV and RHR trends vs. baseline values. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js multi-line chart with HRV and RHR - """ - profile_id = session['profile_id'] - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, resting_hr, hrv_ms - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Vitalwerte vorhanden" - } - } - - labels = [row['date'].isoformat() for row in rows] - hrv_values = [safe_float(row['hrv_ms']) if row['hrv_ms'] else None for row in rows] - rhr_values = [safe_float(row['resting_hr']) if row['resting_hr'] else None for row in rows] - - # Calculate baselines (28d median) - hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id) # This returns % deviation - rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id) # This returns % deviation - - # For chart, we need actual baseline values (approximation) - hrv_filtered = [v for v in hrv_values if v is not None] - rhr_filtered = [v for v in rhr_values if v is not None] - - avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50 - avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60 - - datasets = [ - { - "label": "HRV (ms)", - "data": hrv_values, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y1", - "fill": False - }, - { - "label": "RHR (bpm)", - "data": rhr_values, - "borderColor": "#3B82F6", - "backgroundColor": "rgba(59, 130, 246, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y2", - "fill": False - } - ] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": datasets - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "avg_hrv": round(avg_hrv, 1), - "avg_rhr": round(avg_rhr, 1), - "hrv_vs_baseline_pct": hrv_baseline, - "rhr_vs_baseline_pct": rhr_baseline - }) - } + """HRV/RHR vs baseline (R2).""" + profile_id = session["profile_id"] + return build_hrv_rhr_baseline_chart_payload(profile_id, days) @router.get("/sleep-duration-quality") @@ -1577,107 +1414,9 @@ def get_sleep_duration_quality_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Sleep duration + quality (R3). - - Shows sleep duration and quality score over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js multi-line chart with sleep metrics - """ - profile_id = session['profile_id'] - - duration_data = get_sleep_duration_data(profile_id, days) - quality_data = get_sleep_quality_data(profile_id, days) - - if duration_data['confidence'] == 'insufficient': - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten vorhanden" - } - } - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, total_sleep_min - FROM sleep_log - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten" - } - } - - labels = [row['date'].isoformat() for row in rows] - duration_hours = [safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else None for row in rows] - - # Quality score (simple proxy: % of 8 hours) - quality_scores = [(d / 8 * 100) if d else None for d in duration_hours] - - datasets = [ - { - "label": "Schlafdauer (h)", - "data": duration_hours, - "borderColor": "#3B82F6", - "backgroundColor": "rgba(59, 130, 246, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y1", - "fill": True - }, - { - "label": "Qualität (%)", - "data": quality_scores, - "borderColor": "#1D9E75", - "backgroundColor": "rgba(29, 158, 117, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "yAxisID": "y2", - "fill": False - } - ] - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": datasets - }, - "metadata": serialize_dates({ - "confidence": duration_data['confidence'], - "data_points": len(rows), - "avg_duration_hours": round(duration_data['avg_duration_hours'], 1), - "sleep_quality_score": quality_data.get('sleep_quality_score', 0) - }) - } + """Sleep duration + quality (R3).""" + profile_id = session["profile_id"] + return build_sleep_duration_quality_chart_payload(profile_id, days) @router.get("/sleep-debt") @@ -1685,100 +1424,9 @@ def get_sleep_debt_chart( days: int = Query(default=28, ge=7, le=90), session: dict = Depends(require_auth) ) -> Dict: - """ - Sleep debt accumulation (R4). - - Shows cumulative sleep debt over time. - - Args: - days: Analysis window (7-90 days, default 28) - session: Auth session (injected) - - Returns: - Chart.js line chart with sleep debt - """ - profile_id = session['profile_id'] - - current_debt = calculate_sleep_debt_hours(profile_id) - - if current_debt is None: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten für Schulden-Berechnung" - } - } - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, total_sleep_min - FROM sleep_log - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows: - return { - "chart_type": "line", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Schlafdaten" - } - } - - labels = [row['date'].isoformat() for row in rows] - - # Calculate cumulative debt (target 8h/night) - target_hours = 8.0 - cumulative_debt = 0 - debt_values = [] - - for row in rows: - actual_hours = safe_float(row['total_sleep_min']) / 60 if row['total_sleep_min'] else 0 - daily_deficit = target_hours - actual_hours - cumulative_debt += daily_deficit - debt_values.append(cumulative_debt) - - return { - "chart_type": "line", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Schlafschuld (Stunden)", - "data": debt_values, - "borderColor": "#EF4444", - "backgroundColor": "rgba(239, 68, 68, 0.1)", - "borderWidth": 2, - "tension": 0.3, - "fill": True - } - ] - }, - "metadata": serialize_dates({ - "confidence": calculate_confidence(len(rows), days, "general"), - "data_points": len(rows), - "current_debt_hours": round(current_debt, 1), - "final_debt_hours": round(cumulative_debt, 1) - }) - } + """Sleep debt (R4).""" + profile_id = session["profile_id"] + return build_sleep_debt_chart_payload(profile_id, days) @router.get("/vital-signs-matrix") @@ -1786,123 +1434,9 @@ def get_vital_signs_matrix_chart( days: int = Query(default=7, ge=7, le=30), session: dict = Depends(require_auth) ) -> Dict: - """ - Vital signs matrix (R5). - - Shows latest vital signs as horizontal bar chart. - - Args: - days: Max age of measurements (7-30 days, default 7) - session: Auth session (injected) - - Returns: - Chart.js horizontal bar chart with vital signs - """ - profile_id = session['profile_id'] - - from db import get_db, get_cursor - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - - # Get latest vitals - cur.execute( - """SELECT resting_hr, hrv_ms, vo2_max, spo2, respiratory_rate - FROM vitals_baseline - WHERE profile_id=%s AND date >= %s - ORDER BY date DESC - LIMIT 1""", - (profile_id, cutoff) - ) - vitals_row = cur.fetchone() - - # Get latest blood pressure - cur.execute( - """SELECT systolic, diastolic - FROM blood_pressure_log - WHERE profile_id=%s AND date >= %s - ORDER BY date DESC, time DESC - LIMIT 1""", - (profile_id, cutoff) - ) - bp_row = cur.fetchone() - - if not vitals_row and not bp_row: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine aktuellen Vitalwerte" - } - } - - labels = [] - values = [] - - if vitals_row: - if vitals_row['resting_hr']: - labels.append("Ruhepuls (bpm)") - values.append(safe_float(vitals_row['resting_hr'])) - if vitals_row['hrv_ms']: - labels.append("HRV (ms)") - values.append(safe_float(vitals_row['hrv_ms'])) - if vitals_row['vo2_max']: - labels.append("VO2 Max") - values.append(safe_float(vitals_row['vo2_max'])) - if vitals_row['spo2']: - labels.append("SpO2 (%)") - values.append(safe_float(vitals_row['spo2'])) - if vitals_row['respiratory_rate']: - labels.append("Atemfrequenz") - values.append(safe_float(vitals_row['respiratory_rate'])) - - if bp_row: - if bp_row['systolic']: - labels.append("Blutdruck sys (mmHg)") - values.append(safe_float(bp_row['systolic'])) - if bp_row['diastolic']: - labels.append("Blutdruck dia (mmHg)") - values.append(safe_float(bp_row['diastolic'])) - - if not labels: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Keine Vitalwerte verfügbar" - } - } - - return { - "chart_type": "bar", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Wert", - "data": values, - "backgroundColor": "#1D9E75", - "borderColor": "#085041", - "borderWidth": 1 - } - ] - }, - "metadata": { - "confidence": "medium", - "data_points": len(values), - "note": "Latest measurements within last " + str(days) + " days" - } - } + """Vital signs matrix (R5).""" + profile_id = session["profile_id"] + return build_vital_signs_matrix_chart_payload(profile_id, days) # ── Correlation Charts ────────────────────────────────────────────────────── diff --git a/frontend/src/components/RecoveryCharts.jsx b/frontend/src/components/RecoveryCharts.jsx index a07cdda..6cad7bd 100644 --- a/frontend/src/components/RecoveryCharts.jsx +++ b/frontend/src/components/RecoveryCharts.jsx @@ -1,320 +1,8 @@ -import { useState, useEffect } from 'react' -import { - LineChart, Line, BarChart, Bar, - XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid -} from 'recharts' -import { api } from '../utils/api' -import dayjs from 'dayjs' - -const fmtDate = d => dayjs(d).format('DD.MM') - -function ChartCard({ title, loading, error, children }) { - return ( -
-
- {title} -
- {loading && ( -
-
-
- )} - {error && ( -
- {error} -
- )} - {!loading && !error && children} -
- ) -} +import RecoveryDashboardOverview from './RecoveryDashboardOverview' /** - * Recovery Charts Component (R1-R5) - * - * Displays 5 recovery chart endpoints: - * - Recovery Score Timeline (R1) - * - HRV/RHR vs Baseline (R2) - * - Sleep Duration + Quality (R3) - * - Sleep Debt (R4) - * - Vital Signs Matrix (R5) + * @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days → period). */ export default function RecoveryCharts({ days = 28 }) { - const [recoveryData, setRecoveryData] = useState(null) - const [hrvRhrData, setHrvRhrData] = useState(null) - const [sleepData, setSleepData] = useState(null) - const [debtData, setDebtData] = useState(null) - const [vitalsData, setVitalsData] = useState(null) - - const [loading, setLoading] = useState({}) - const [errors, setErrors] = useState({}) - - useEffect(() => { - loadCharts() - }, [days]) - - const loadCharts = async () => { - // Load all 5 charts in parallel - await Promise.all([ - loadRecoveryScore(), - loadHrvRhr(), - loadSleepQuality(), - loadSleepDebt(), - loadVitalSigns() - ]) - } - - const loadRecoveryScore = async () => { - setLoading(l => ({...l, recovery: true})) - setErrors(e => ({...e, recovery: null})) - try { - const data = await api.getRecoveryScoreChart(days) - setRecoveryData(data) - } catch (err) { - setErrors(e => ({...e, recovery: err.message})) - } finally { - setLoading(l => ({...l, recovery: false})) - } - } - - const loadHrvRhr = async () => { - setLoading(l => ({...l, hrvRhr: true})) - setErrors(e => ({...e, hrvRhr: null})) - try { - const data = await api.getHrvRhrBaselineChart(days) - setHrvRhrData(data) - } catch (err) { - setErrors(e => ({...e, hrvRhr: err.message})) - } finally { - setLoading(l => ({...l, hrvRhr: false})) - } - } - - const loadSleepQuality = async () => { - setLoading(l => ({...l, sleep: true})) - setErrors(e => ({...e, sleep: null})) - try { - const data = await api.getSleepDurationQualityChart(days) - setSleepData(data) - } catch (err) { - setErrors(e => ({...e, sleep: err.message})) - } finally { - setLoading(l => ({...l, sleep: false})) - } - } - - const loadSleepDebt = async () => { - setLoading(l => ({...l, debt: true})) - setErrors(e => ({...e, debt: null})) - try { - const data = await api.getSleepDebtChart(days) - setDebtData(data) - } catch (err) { - setErrors(e => ({...e, debt: err.message})) - } finally { - setLoading(l => ({...l, debt: false})) - } - } - - const loadVitalSigns = async () => { - setLoading(l => ({...l, vitals: true})) - setErrors(e => ({...e, vitals: null})) - try { - const data = await api.getVitalSignsMatrixChart(7) // Last 7 days - setVitalsData(data) - } catch (err) { - setErrors(e => ({...e, vitals: err.message})) - } finally { - setLoading(l => ({...l, vitals: false})) - } - } - - // R1: Recovery Score Timeline - const renderRecoveryScore = () => { - if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { - return
- Keine Recovery-Daten vorhanden -
- } - - const chartData = recoveryData.data.labels.map((label, i) => ({ - date: fmtDate(label), - score: recoveryData.data.datasets[0]?.data[i] - })) - - return ( - <> - - - - - - - - - -
- Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge -
- - ) - } - - // R2: HRV/RHR vs Baseline - const renderHrvRhr = () => { - if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') { - return
- Keine Vitalwerte vorhanden -
- } - - const chartData = hrvRhrData.data.labels.map((label, i) => ({ - date: fmtDate(label), - hrv: hrvRhrData.data.datasets[0]?.data[i], - rhr: hrvRhrData.data.datasets[1]?.data[i] - })) - - return ( - <> - - - - - - - - - - - -
- HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm -
- - ) - } - - // R3: Sleep Duration + Quality - const renderSleepQuality = () => { - if (!sleepData || sleepData.metadata?.confidence === 'insufficient') { - return
- Keine Schlafdaten vorhanden -
- } - - const chartData = sleepData.data.labels.map((label, i) => ({ - date: fmtDate(label), - duration: sleepData.data.datasets[0]?.data[i], - quality: sleepData.data.datasets[1]?.data[i] - })) - - return ( - <> - - - - - - - - - - - -
- Ø {sleepData.metadata.avg_duration_hours}h Schlaf -
- - ) - } - - // R4: Sleep Debt - const renderSleepDebt = () => { - if (!debtData || debtData.metadata?.confidence === 'insufficient') { - return
- Keine Schlafdaten für Schulden-Berechnung -
- } - - const chartData = debtData.data.labels.map((label, i) => ({ - date: fmtDate(label), - debt: debtData.data.datasets[0]?.data[i] - })) - - return ( - <> - - - - - - - - - -
- Aktuelle Schuld: {debtData.metadata.current_debt_hours.toFixed(1)}h -
- - ) - } - - // R5: Vital Signs Matrix (Bar) - const renderVitalSigns = () => { - if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { - return
- Keine aktuellen Vitalwerte -
- } - - const chartData = vitalsData.data.labels.map((label, i) => ({ - name: label, - value: vitalsData.data.datasets[0]?.data[i] - })) - - return ( - <> - - - - - - - - - -
- Letzte {vitalsData.metadata.data_points} Messwerte (7 Tage) -
- - ) - } - - return ( -
- - {renderRecoveryScore()} - - - - {renderHrvRhr()} - - - - {renderSleepQuality()} - - - - {renderSleepDebt()} - - - - {renderVitalSigns()} - -
- ) + return } diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx new file mode 100644 index 0000000..31f0802 --- /dev/null +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -0,0 +1,402 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from 'recharts' +import { api } from '../utils/api' +import KpiTilesOverview from './KpiTilesOverview' +import { getStatusColor } from '../utils/interpret' +import dayjs from 'dayjs' + +const fmtDate = (d) => dayjs(d).format('DD.MM.') + +function ChartCard({ title, loading, error, children }) { + return ( +
+
{title}
+ {loading && ( +
+
+
+ )} + {error && ( +
{error}
+ )} + {!loading && !error && children} +
+ ) +} + +/** + * Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics). + */ +export default function RecoveryDashboardOverview({ + period: periodProp, + onPeriodChange, + hidePeriodSelector = false, +}) { + const nav = useNavigate() + const [internalPeriod, setInternalPeriod] = useState(28) + const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' + const period = controlled ? periodProp : internalPeriod + const setPeriod = controlled ? onPeriodChange : setInternalPeriod + + const [viz, setViz] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setErr(null) + api + .getRecoveryDashboardViz(period) + .then((v) => { + if (!cancelled) setViz(v) + }) + .catch((e) => { + if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [period]) + + if (loading) { + return ( +
+
Erholung & Vitalwerte
+
+
+ ) + } + + if (err) { + return ( +
+
Erholung & Vitalwerte
+
{err}
+
+ ) + } + + if (!viz?.has_recovery_data) { + return ( +
+
Erholung & Vitalwerte
+

+ {viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst + oder importierst, erscheinen Auswertungen hier. +

+ +
+ ) + } + + const recoveryData = viz.charts?.recovery_score + const hrvRhrData = viz.charts?.hrv_rhr + const sleepData = viz.charts?.sleep_duration_quality + const debtData = viz.charts?.sleep_debt + const vitalsData = viz.charts?.vital_signs_matrix + + const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ + ...t, + sublabel: + typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, + })) + const insights = viz.progress_insights || [] + const eff = viz.effective_window_days + const cDays = viz.chart_days_used + const vDays = viz.vital_matrix_days_used + + const showPeriodDropdown = !hidePeriodSelector && !controlled + + const renderRecoveryScore = () => { + if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Recovery-Daten im Fenster +
+ ) + } + const chartData = recoveryData.data.labels.map((label, i) => ({ + date: fmtDate(label), + score: recoveryData.data.datasets[0]?.data[i], + })) + return ( + <> + + + + + + + + + +
+ Aktuell: {recoveryData.metadata.current_score}/100 · {recoveryData.metadata.data_points} Einträge +
+ + ) + } + + const renderHrvRhr = () => { + if (!hrvRhrData || hrvRhrData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Vitalwerte im Fenster +
+ ) + } + const chartData = hrvRhrData.data.labels.map((label, i) => ({ + date: fmtDate(label), + hrv: hrvRhrData.data.datasets[0]?.data[i], + rhr: hrvRhrData.data.datasets[1]?.data[i], + })) + return ( + <> + + + + + + + + + + + +
+ HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm +
+ + ) + } + + const renderSleepQuality = () => { + if (!sleepData || sleepData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Schlafdaten im Fenster +
+ ) + } + const chartData = sleepData.data.labels.map((label, i) => ({ + date: fmtDate(label), + duration: sleepData.data.datasets[0]?.data[i], + quality: sleepData.data.datasets[1]?.data[i], + })) + return ( + <> + + + + + + + + + + + +
+ Ø {sleepData.metadata.avg_duration_hours}h Schlaf +
+ + ) + } + + const renderSleepDebt = () => { + if (!debtData || debtData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine Schlafdaten für Schulden-Berechnung +
+ ) + } + const chartData = debtData.data.labels.map((label, i) => ({ + date: fmtDate(label), + debt: debtData.data.datasets[0]?.data[i], + })) + const curDebt = debtData.metadata?.current_debt_hours + return ( + <> + + + + + + + + + +
+ Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h +
+ + ) + } + + const renderVitalSigns = () => { + if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') { + return ( +
+ Keine aktuellen Vitalwerte +
+ ) + } + const chartData = vitalsData.data.labels.map((label, i) => ({ + name: label, + value: vitalsData.data.datasets[0]?.data[i], + })) + return ( + <> + + + + + + + + + +
+ Letzte {vitalsData.metadata.data_points} Messwerte ({vDays} Tage) +
+ + ) + } + + return ( +
+
+ Erholung & Vitalwerte + {showPeriodDropdown ? ( + + ) : null} +
+ +

+ Auswertung aus dem Recovery-Data-Layer (Issue 53). Fenster ca. {eff} Tage · Charts{' '} + {cDays} Tage · Vital-Matrix {vDays} Tage. +

+ + + + {insights.length > 0 ? ( +
+
Einschätzungen
+
+ {insights.map((ins) => ( +
+
{ins.title}
+
{ins.body}
+
+ ))} +
+
+ ) : null} + +
Diagramme
+ + {renderRecoveryScore()} + {renderHrvRhr()} + {renderSleepQuality()} + {renderSleepDebt()} + {renderVitalSigns()} +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx index 4047427..218cb81 100644 --- a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx +++ b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx @@ -1,9 +1,9 @@ import { useNavigate } from 'react-router-dom' -import RecoveryCharts from '../RecoveryCharts' +import RecoveryDashboardOverview from '../RecoveryDashboardOverview' import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' /** - * Erholung R1–R5 (wie Verlauf Erholung). + * Erholung Layer 2b (ein Bundle-Request). Link zum Verlauf unter Fitness. * @param {{ refreshTick?: number, chartDays?: number }} props */ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }) { @@ -11,22 +11,22 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays } const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 28 return ( -
+
-
Erholung — Charts
+
Erholung — Übersicht
Schlaf, Recovery, Vitalwerte · {days} Tage
- +
) } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 2a78d14..36c73bf 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -15,7 +15,7 @@ import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '.. import Markdown from '../utils/Markdown' import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' -import RecoveryCharts from '../components/RecoveryCharts' +import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' import KpiTilesOverview from '../components/KpiTilesOverview' import dayjs from 'dayjs' import 'dayjs/locale/de' @@ -1108,11 +1108,15 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA

- Auswertung ausschließlich aus dem Fitness-Bundle (Data-Layer / Issue 53). Zeitraum-Buttons steuern dasselbe - Fenster wie die API. + Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.

+
+ Erholung (Schlaf, HRV, Vitalwerte) +
+ + {hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
)} - {hasList ? ( - - ) : null} +
) } @@ -1432,32 +1439,10 @@ function PhotoGrid() { } // ── Main ────────────────────────────────────────────────────────────────────── -// ── Recovery Section ────────────────────────────────────────────────────────── -function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { - const [period, setPeriod] = useState(28) - - return ( -
- - - -
- Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick. -
- - {/* Recovery Charts (Phase 0c) */} - - - -
- ) -} - const TABS = [ { id:'body', label:'⚖️ Körper' }, { id:'nutrition', label:'🍽️ Ernährung' }, { id:'activity', label:'🏋️ Fitness' }, - { id:'recovery', label:'😴 Erholung' }, { id:'correlation', label:'🔗 Korrelation' }, { id:'photos', label:'📷 Fotos' }, ] @@ -1497,6 +1482,10 @@ export default function History() { useEffect(() => { const t = location.state?.tab + if (t === 'recovery') { + setTab('activity') + return + } if (t && TABS.some(x => x.id === t)) setTab(t) }, [location.state?.tab]) @@ -1544,7 +1533,6 @@ export default function History() { {tab==='body' && } {tab==='nutrition' && } {tab==='activity' && } - {tab==='recovery' && } {tab==='correlation' && } {tab==='photos' && }
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 9185df7..18ba7e9 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -641,6 +641,8 @@ export const api = { getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`), /** Layer 2b: Fitness-Übersicht — KPI + Volumen/Typ-Charts (activity_metrics) */ getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`), + /** Layer 2b: Erholung — KPI, Insights, Charts R1–R5 (recovery_metrics) */ + getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`), getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`), From 33b08a8d8250f579eb237bcc23661f6d9b9bf5a3 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 08:21:50 +0200 Subject: [PATCH 5/6] fix: update sleep chart payloads to use duration_minutes and quality_score - Changed SQL queries in `build_sleep_duration_quality_chart_payload` and `build_sleep_debt_chart_payload` to select `duration_minutes` instead of `total_sleep_min`. - Updated calculations for sleep duration and quality scores to reflect the new field names, ensuring accurate data representation in the recovery charts. --- backend/data_layer/recovery_chart_payloads.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py index 20c87f1..0052024 100644 --- a/backend/data_layer/recovery_chart_payloads.py +++ b/backend/data_layer/recovery_chart_payloads.py @@ -211,7 +211,7 @@ def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Di with get_db() as conn: cur = get_cursor(conn) cur.execute( - """SELECT date, total_sleep_min + """SELECT date, duration_minutes FROM sleep_log WHERE profile_id=%s AND date >= %s ORDER BY date""", @@ -231,7 +231,9 @@ def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Di } 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] + 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] @@ -266,7 +268,7 @@ def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Di "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_quality_score": quality_data.get("quality_score", 0), } ), } @@ -295,7 +297,7 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] with get_db() as conn: cur = get_cursor(conn) cur.execute( - """SELECT date, total_sleep_min + """SELECT date, duration_minutes FROM sleep_log WHERE profile_id=%s AND date >= %s ORDER BY date""", @@ -321,7 +323,7 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any] debt_values = [] for row in rows: - actual_hours = safe_float(row["total_sleep_min"]) / 60 if row["total_sleep_min"] else 0 + 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) From d3cb9d4ad94d2357a0b32c3c52cea3caf47d1cec Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 20 Apr 2026 08:24:23 +0200 Subject: [PATCH 6/6] fix: update SQL query in recovery chart payloads for accurate date filtering - Modified the SQL query in `build_vital_signs_matrix_chart_payload` to use `measured_at::date` for date comparisons, ensuring correct data retrieval based on the measurement date. - Adjusted the order of results to sort by `measured_at` instead of `date`, improving the accuracy of the latest vital signs data fetched. --- backend/data_layer/recovery_chart_payloads.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/data_layer/recovery_chart_payloads.py b/backend/data_layer/recovery_chart_payloads.py index 0052024..149ad26 100644 --- a/backend/data_layer/recovery_chart_payloads.py +++ b/backend/data_layer/recovery_chart_payloads.py @@ -377,8 +377,8 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s cur.execute( """SELECT systolic, diastolic FROM blood_pressure_log - WHERE profile_id=%s AND date >= %s - ORDER BY date DESC, time DESC + WHERE profile_id=%s AND measured_at::date >= %s::date + ORDER BY measured_at DESC LIMIT 1""", (profile_id, cutoff), )