feat: introduce fitness dashboard overview and enhance activity metrics
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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.
This commit is contained in:
Lars 2026-04-19 21:27:12 +02:00
parent d7304c1a44
commit b5c5f2f612
11 changed files with 739 additions and 124 deletions

View File

@ -330,24 +330,30 @@ def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
return int(row['session_count']) if row else None return int(row['session_count']) if row else None
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]: def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]:
"""Calculate percentage of quality sessions (good or better) last 28 days""" """Anteil qualitativ guter Sessions (quality_label) im Zeitfenster ``days``."""
if days < 1:
days = 28
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute(""" cur.execute(
"""
SELECT SELECT
COUNT(*) as total, COUNT(*) as total,
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
FROM activity_log FROM activity_log
WHERE profile_id = %s WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '28 days' AND date >= %s
""", (profile_id,)) """,
(profile_id, cutoff),
)
row = cur.fetchone() row = cur.fetchone()
if not row or row['total'] == 0: if not row or row["total"] == 0:
return None return None
pct = (row['quality_count'] / row['total']) * 100 pct = (row["quality_count"] / row["total"]) * 100
return int(pct) return int(pct)
@ -1222,3 +1228,128 @@ def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
"parameters": rows, "parameters": rows,
"meta": {"count": len(rows), "scope": "global_active_catalog"}, "meta": {"count": len(rows), "scope": "global_active_catalog"},
} }
# ============================================================================
# Chart payloads (Phase 0c / Layer 1) — gemeinsam mit charts-Router und Layer-2b-Bundles
# ============================================================================
def build_training_volume_chart_payload(profile_id: str, weeks: int) -> Dict[str, Any]:
"""
Wöchentliches Trainingsvolumen (Minuten) gleiche Logik wie GET /api/charts/training-volume.
"""
if weeks < 4:
weeks = 4
if weeks > 52:
weeks = 52
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT
DATE_TRUNC('week', date) as week_start,
SUM(duration_min) as total_minutes,
COUNT(*) as session_count
FROM activity_log
WHERE profile_id=%s AND date >= %s
GROUP BY week_start
ORDER BY week_start""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Aktivitätsdaten vorhanden",
},
}
labels = [row["week_start"].strftime("KW %V") for row in rows]
values = [safe_float(row["total_minutes"]) for row in rows]
confidence = calculate_confidence(len(rows), weeks * 7, "general")
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Trainingsminuten",
"data": values,
"backgroundColor": "#1D9E75",
"borderColor": "#085041",
"borderWidth": 1,
}
],
},
"metadata": serialize_dates(
{
"confidence": confidence,
"data_points": len(rows),
"avg_minutes_week": round(sum(values) / len(values), 1) if values else 0,
"total_sessions": sum(row["session_count"] for row in rows),
}
),
}
def build_training_type_distribution_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""
Trainingstyp-Verteilung gleiche Logik wie GET /api/charts/training-type-distribution.
"""
dist_data = get_training_type_distribution_data(profile_id, days)
if dist_data["confidence"] == "insufficient":
return {
"chart_type": "pie",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Trainingstypen-Daten",
},
}
labels = [item["category"] for item in dist_data["distribution"]]
values = [item["count"] for item in dist_data["distribution"]]
colors = [
"#1D9E75",
"#3B82F6",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#10B981",
"#F97316",
"#06B6D4",
]
return {
"chart_type": "pie",
"data": {
"labels": labels,
"datasets": [
{
"data": values,
"backgroundColor": colors[: len(values)],
"borderWidth": 2,
"borderColor": "#fff",
}
],
},
"metadata": {
"confidence": dist_data["confidence"],
"total_sessions": dist_data["total_sessions"],
"categorized_sessions": dist_data["categorized_sessions"],
"uncategorized_sessions": dist_data["uncategorized_sessions"],
},
}

View File

@ -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: 150300 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 (0100)",
"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

View File

@ -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",
},
}

View File

@ -33,6 +33,7 @@ from data_layer.body_metrics import (
) )
from data_layer.body_viz import get_body_history_viz_bundle from data_layer.body_viz import get_body_history_viz_bundle
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
from data_layer.nutrition_metrics import ( from data_layer.nutrition_metrics import (
get_nutrition_average_data, get_nutrition_average_data,
get_protein_targets_data, get_protein_targets_data,
@ -44,13 +45,14 @@ from data_layer.nutrition_metrics import (
) )
from data_layer.activity_metrics import ( from data_layer.activity_metrics import (
get_activity_summary_data, get_activity_summary_data,
get_training_type_distribution_data,
calculate_training_minutes_week, calculate_training_minutes_week,
calculate_quality_sessions_pct, calculate_quality_sessions_pct,
calculate_proxy_internal_load_7d, calculate_proxy_internal_load_7d,
calculate_monotony_score, calculate_monotony_score,
calculate_strain_score, calculate_strain_score,
calculate_ability_balance calculate_ability_balance,
build_training_volume_chart_payload,
build_training_type_distribution_chart_payload,
) )
from data_layer.recovery_metrics import ( from data_layer.recovery_metrics import (
get_sleep_duration_data, get_sleep_duration_data,
@ -288,6 +290,26 @@ def get_nutrition_history_viz(
return serialize_dates(bundle) return serialize_dates(bundle)
@router.get("/fitness-dashboard-viz")
def get_fitness_dashboard_viz(
days: int = Query(
default=28,
ge=7,
le=9999,
description="Analysefenster in Tagen (9999 = lange Historie)",
),
session: dict = Depends(require_auth),
) -> Dict:
"""
Layer 2b: Fitness-Übersicht KPI-Kacheln + Volumen- und Typ-Verteilungs-Charts.
Daten aus activity_metrics (gleiche Payloads wie training-volume / training-type-distribution).
"""
profile_id = session["profile_id"]
bundle = get_fitness_dashboard_viz_bundle(profile_id, days)
return serialize_dates(bundle)
@router.get("/circumferences") @router.get("/circumferences")
def get_circumferences_chart( def get_circumferences_chart(
max_age_days: int = Query(default=90, ge=7, le=365), max_age_days: int = Query(default=90, ge=7, le=365),
@ -1051,66 +1073,7 @@ def get_training_volume_chart(
Chart.js bar chart with weekly training minutes Chart.js bar chart with weekly training minutes
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
return build_training_volume_chart_payload(profile_id, weeks)
from db import get_db, get_cursor
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d')
# Get weekly aggregates
cur.execute(
"""SELECT
DATE_TRUNC('week', date) as week_start,
SUM(duration_min) as total_minutes,
COUNT(*) as session_count
FROM activity_log
WHERE profile_id=%s AND date >= %s
GROUP BY week_start
ORDER BY week_start""",
(profile_id, cutoff)
)
rows = cur.fetchall()
if not rows:
return {
"chart_type": "bar",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Aktivitätsdaten vorhanden"
}
}
labels = [row['week_start'].strftime('KW %V') for row in rows]
values = [safe_float(row['total_minutes']) for row in rows]
confidence = calculate_confidence(len(rows), weeks * 7, "general")
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Trainingsminuten",
"data": values,
"backgroundColor": "#1D9E75",
"borderColor": "#085041",
"borderWidth": 1
}
]
},
"metadata": serialize_dates({
"confidence": confidence,
"data_points": len(rows),
"avg_minutes_week": round(sum(values) / len(values), 1) if values else 0,
"total_sessions": sum(row['session_count'] for row in rows)
})
}
@router.get("/training-type-distribution") @router.get("/training-type-distribution")
@ -1131,52 +1094,7 @@ def get_training_type_distribution_chart(
Chart.js pie chart with training categories Chart.js pie chart with training categories
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
return build_training_type_distribution_chart_payload(profile_id, days)
dist_data = get_training_type_distribution_data(profile_id, days)
if dist_data['confidence'] == 'insufficient':
return {
"chart_type": "pie",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Trainingstypen-Daten"
}
}
labels = [item['category'] for item in dist_data['distribution']]
values = [item['count'] for item in dist_data['distribution']]
# Color palette for training categories
colors = [
"#1D9E75", "#3B82F6", "#F59E0B", "#EF4444",
"#8B5CF6", "#10B981", "#F97316", "#06B6D4"
]
return {
"chart_type": "pie",
"data": {
"labels": labels,
"datasets": [
{
"data": values,
"backgroundColor": colors[:len(values)],
"borderWidth": 2,
"borderColor": "#fff"
}
]
},
"metadata": {
"confidence": dist_data['confidence'],
"total_sessions": dist_data['total_sessions'],
"categorized_sessions": dist_data['categorized_sessions'],
"uncategorized_sessions": dist_data['uncategorized_sessions']
}
}
@router.get("/quality-sessions") @router.get("/quality-sessions")

View File

@ -27,6 +27,7 @@ Dieser Ordner ist **immer mit Git versioniert**. Er ergänzt **`.claude/docs/`**
| `issue-51-prompt-page-assignment.md` | | `issue-51-prompt-page-assignment.md` |
| `issue-52-blood-pressure-dual-targets.md` | | `issue-52-blood-pressure-dual-targets.md` |
| `issue-53-phase-0c-multi-layer-architecture.md` | | `issue-53-phase-0c-multi-layer-architecture.md` |
| `issue-fitness-dashboard-layer2b.md` |
| `issue-54-dynamic-placeholder-system.md` | | `issue-54-dynamic-placeholder-system.md` |
| `issue-55-dynamic-aggregation-methods.md` | | `issue-55-dynamic-aggregation-methods.md` |
| `issue-76-training-quality-goal-list-filter.md` | | `issue-76-training-quality-goal-list-filter.md` |
@ -56,4 +57,4 @@ Themen-Übersicht (lokal): **`.claude/docs/GITEA_ISSUES_INDEX.md`**
--- ---
**Stand:** 2026-04-08 **Stand:** 2026-04-19

View File

@ -0,0 +1,54 @@
# Fitness-Dashboard (Layer 2b) Abnahme & technische Zuordnung
**Status:** umgesetzt (Frontend + Backend)
**Bezug:** Issue #53 (Phase 0c) Layer 1 → Layer 2b Bundle → UI nur Darstellung
**Stand:** 2026-04-19
---
## Ziel
- Eine **Fitness-Übersicht** auf `/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 A3A8 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.

View File

@ -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 (
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (err) {
return (
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<div style={{ color: 'var(--danger)' }}>{err}</div>
</div>
)
}
if (!viz?.has_activity_entries) {
return (
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45 }}>
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Kennzahlen und
Diagramme hier.
</p>
</div>
)
}
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 (
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Fitness-Übersicht</span>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}>
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
>
{PERIODS.map((p) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Aktivitäts-Data-Layer). Zusammenfassung
ca. <strong>{eff}</strong> Tage · Volumen-Chart <strong>{wUsed}</strong> Wochen · Typ-Verteilung{' '}
<strong>{dTyp}</strong> Tage
{viz.last_updated ? (
<>
{' '}
· letzte Aktivität <strong>{viz.last_updated}</strong>
</>
) : null}
.
</p>
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: 16,
marginTop: 8,
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Trainingsvolumen (Minuten / Woche)
</div>
{volRows.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={0} angle={-35} textAnchor="end" height={48} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
/>
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
</BarChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
)}
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Training nach Kategorie
</div>
{pieData.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={72}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
)}
</div>
</div>
</div>
)
}

View File

@ -56,7 +56,7 @@ export const CAPTURE_HUB_TILES = [
}, },
{ {
icon: '🏋️', icon: '🏋️',
label: 'Aktivität', label: 'Fitness',
sub: 'Training manuell oder Apple Health importieren', sub: 'Training manuell oder Apple Health importieren',
to: '/activity', to: '/activity',
color: '#D4537E', color: '#D4537E',

View File

@ -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 { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api' import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
import TrainingTypeSelect from '../components/TrainingTypeSelect' import TrainingTypeSelect from '../components/TrainingTypeSelect'
import BulkCategorize from '../components/BulkCategorize' import BulkCategorize from '../components/BulkCategorize'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -912,7 +913,12 @@ export default function ActivityPage() {
return ( return (
<div className="capture-page"> <div className="capture-page">
<h1 className="page-title">Aktivität</h1> <h1 className="page-title">Fitness</h1>
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: -8, marginBottom: 12 }}>
Auswertung (Data-Layer) und Erfassung an einem Ort.
</p>
<FitnessDashboardOverview />
<div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}> <div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button> <button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>

View File

@ -328,10 +328,10 @@ function InsightBox({ insights, slugs, onRequest, loading }) {
const [expanded, setExpanded] = useState(null) const [expanded, setExpanded] = useState(null)
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung', const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung',
aktivitaet:'Aktivität',gesundheit:'Gesundheit',ziele:'Ziele', aktivitaet:'Fitness',gesundheit:'Gesundheit',ziele:'Ziele',
pipeline:'🔬 Mehrstufige Analyse', pipeline:'🔬 Mehrstufige Analyse',
pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung', pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung',
pipeline_activity:'Pipeline Aktivität',pipeline_synthesis:'Pipeline Synthese', pipeline_activity:'Pipeline Fitness',pipeline_synthesis:'Pipeline Synthese',
pipeline_goals:'Pipeline Ziele'} pipeline_goals:'Pipeline Ziele'}
return ( return (
<div style={{marginTop:14}}> <div style={{marginTop:14}}>
@ -535,7 +535,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
<BodyGoalsStrip grouped={groupedGoals} /> <BodyGoalsStrip grouped={groupedGoals} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}> <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf Aktivität</strong>. Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf Fitness</strong>.
</p> </p>
{viz?.meta?.layer_2a_alignment && ( {viz?.meta?.layer_2a_alignment && (
@ -1101,7 +1101,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
const [period, setPeriod] = useState(30) const [period, setPeriod] = useState(30)
if (!activities?.length) return ( if (!activities?.length) return (
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/> <EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Fitness erfassen"/>
) )
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
@ -1132,7 +1132,7 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
return ( return (
<div> <div>
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/> <SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/> <PeriodSelector value={period} onChange={setPeriod}/>
{/* Issue #31: Show active global quality filter */} {/* Issue #31: Show active global quality filter */}
@ -1518,7 +1518,7 @@ function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }
const TABS = [ const TABS = [
{ id:'body', label:'⚖️ Körper' }, { id:'body', label:'⚖️ Körper' },
{ id:'nutrition', label:'🍽️ Ernährung' }, { id:'nutrition', label:'🍽️ Ernährung' },
{ id:'activity', label:'🏋️ Aktivität' }, { id:'activity', label:'🏋️ Fitness' },
{ id:'recovery', label:'😴 Erholung' }, { id:'recovery', label:'😴 Erholung' },
{ id:'correlation', label:'🔗 Korrelation' }, { id:'correlation', label:'🔗 Korrelation' },
{ id:'photos', label:'📷 Fotos' }, { id:'photos', label:'📷 Fotos' },

View File

@ -639,6 +639,8 @@ export const api = {
getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`), getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`),
/** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */ /** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */
getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`), getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`),
/** Layer 2b: Fitness-Übersicht — KPI + Volumen/Typ-Charts (activity_metrics) */
getFitnessDashboardViz: (days=28) => req(`/charts/fitness-dashboard-viz?days=${days}`),
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`), getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),