""" 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_load_monitoring_chart_payload, build_quality_sessions_chart_payload, 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, get_training_volume_two_week_delta, ) from data_layer.fitness_interpretation import ( build_fitness_dashboard_kpi_tiles, build_fitness_progress_insights, ) 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": {}, "progress_insights": [], "volume_delta": {}, "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))) load_days = min(90, max(14, min(eff_days, 365))) volume_chart = build_training_volume_chart_payload(profile_id, weeks_vol) type_chart = build_training_type_distribution_chart_payload(profile_id, dist_days) quality_chart = build_quality_sessions_chart_payload(profile_id, dist_days) load_chart = build_load_monitoring_chart_payload(profile_id, load_days) quality_days = dist_days quality_pct = calculate_quality_sessions_pct(profile_id, quality_days) 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) vol_delta = get_training_volume_two_week_delta(profile_id) kpi_tiles = build_fitness_dashboard_kpi_tiles( summary, minutes_7d, quality_pct, quality_days, activity_score, vo2_trend, top_focus, vol_delta, ) load_meta = load_chart.get("metadata") or {} if not isinstance(load_meta, dict): load_meta = {} progress_insights = build_fitness_progress_insights(vol_delta, load_meta, quality_pct) conf = summary.get("confidence") or "medium" if summary.get("activity_count", 0) == 0: conf = "insufficient" 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": [], "progress_insights": progress_insights, "volume_delta": vol_delta, "charts": { "training_volume": volume_chart, "training_type_distribution": type_chart, "quality_sessions": quality_chart, "load_monitoring": load_chart, }, "load_chart_days_used": load_days, "meta": { "layer_1": "activity_metrics", "layer_2b": "fitness_viz", "issue": "53-layer-2b-fitness", }, }