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}`),