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.
This commit is contained in:
parent
d7304c1a44
commit
b5c5f2f612
|
|
@ -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"],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
167
backend/data_layer/fitness_interpretation.py
Normal file
167
backend/data_layer/fitness_interpretation.py
Normal 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: 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
|
||||
125
backend/data_layer/fitness_viz.py
Normal file
125
backend/data_layer/fitness_viz.py
Normal 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",
|
||||
},
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
docs/issues/issue-fitness-dashboard-layer2b.md
Normal file
54
docs/issues/issue-fitness-dashboard-layer2b.md
Normal 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 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.
|
||||
211
frontend/src/components/FitnessDashboardOverview.jsx
Normal file
211
frontend/src/components/FitnessDashboardOverview.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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'}}>
|
||||
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div style={{marginTop:14}}>
|
||||
|
|
@ -535,7 +535,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
|||
<BodyGoalsStrip grouped={groupedGoals} />
|
||||
|
||||
<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>
|
||||
|
||||
{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 (
|
||||
<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')
|
||||
|
||||
|
|
@ -1132,7 +1132,7 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
|
||||
return (
|
||||
<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}/>
|
||||
|
||||
{/* 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' },
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user