""" 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 _vol_delta_status(delta_pct: Optional[float], prior7: int, last7: int) -> str: if delta_pct is None: if last7 > 0 and prior7 == 0: return "good" return "warn" if delta_pct >= 5: return "good" if delta_pct >= -10: return "warn" return "bad" def build_fitness_progress_insights( vol_delta: Dict[str, Any], load_meta: Dict[str, Any], quality_pct: Optional[int], ) -> List[Dict[str, Any]]: """ Kurz-Aussagen für die UI (Layer 2b), keine zweite Datenquelle. """ out: List[Dict[str, Any]] = [] if vol_delta.get("has_data"): last7 = int(vol_delta.get("last7_min") or 0) prev7 = int(vol_delta.get("prior7_min") or 0) d = vol_delta.get("delta_pct") if d is not None: sign = "+" if d > 0 else "" body = ( f"Trainingsminuten letzte 7 Tage ({last7} min) vs. Vorwoche ({prev7} min): " f"{sign}{d} %." ) elif last7 > 0 and prev7 == 0: body = f"Mehr Volumen als in der Vorwoche: zuletzt {last7} min (Vorwoche 0 min)." else: body = "Zu wenig Daten für einen Vorwochen-Vergleich." out.append( { "key": "ins_vol_trend", "tone": _vol_delta_status( float(d) if d is not None else None, prev7, last7 ), "title": "Volumen-Trend", "body": body, } ) acwr = load_meta.get("acwr") st = load_meta.get("acwr_status") if acwr is not None and isinstance(load_meta, dict) and load_meta.get("data_points", 0) > 0: if st == "optimal": tone = "good" hint = "Akute zu chronischer Last (ACWR) liegt im oft empfohlenen Bereich (ca. 0,8–1,3)." else: tone = "warn" hint = ( "ACWR außerhalb des häufig genannten Zielkorridors — bei anhaltender Belastung " "Erholung oder Volumen prüfen (Proxy-Modell)." ) out.append( { "key": "ins_acwr", "tone": tone, "title": "Belastungsverhältnis (ACWR)", "body": f"Verhältnis akut (7 Tage) zu chronisch (28 Tage): {float(acwr):.2f}. {hint}", } ) if quality_pct is not None: tone = "good" if quality_pct >= 60 else "warn" if quality_pct >= 40 else "bad" out.append( { "key": "ins_quality", "tone": tone, "title": "Session-Qualität", "body": f"{quality_pct} % der Sessions sind als «gut» oder besser eingestuft — Grundlage für progressive Belastung.", } ) return out def build_fitness_dashboard_kpi_tiles( summary: Dict[str, Any], minutes_7d: Optional[int], quality_pct: Optional[int], quality_window_days: int, activity_score: Optional[int], vo2_trend: Optional[float], top_focus: Optional[Dict[str, Any]], vol_delta: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: spw = summary.get("sessions_per_week") try: 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]] = [] if vol_delta and vol_delta.get("has_data"): d = vol_delta.get("delta_pct") last7 = int(vol_delta.get("last7_min") or 0) prev7 = int(vol_delta.get("prior7_min") or 0) if d is not None: sign = "+" if float(d) > 0 else "" v_s = f"{sign}{d:.1f} %".replace(".", ",") sub = f"{last7} min vs. {prev7} min (7-Tage-Fenster)" elif last7 > 0 and prev7 == 0: v_s = "neu" sub = f"{last7} min letzte Woche" else: v_s = "—" sub = "Vergleich Vorwoche" vd_st = _vol_delta_status(float(d) if d is not None else None, prev7, last7) tiles.append( { "key": "volume_vs_prior_week", "category": "Volumen vs. Vorwoche", "icon": "📈", "value": v_s, "sublabel": sub, "status": vd_st, "verdict": _verdict(vd_st), "hoverTop": "Fortschritt Trainingsminuten", "hoverBody": "Letzte 7 Kalendertage vs. die 7 Tage davor (activity_log).", "keys": ["training_minutes_week", "activity_summary"], } ) tiles.extend( [ { "key": "minutes_week", "category": "Minuten (7 Tage)", "icon": "⏱", "value": f"{minutes_7d} min" if minutes_7d is not None else "—", "sublabel": "WHO: 150–300 min/Woche", "status": m_status, "verdict": _verdict(m_status), "hoverTop": "Summe Trainingsminuten (letzte 7 Tage)", "hoverBody": "Gleiche Quelle wie Platzhalter training_minutes_week.", "keys": ["training_minutes_week", "activity_score"], }, { "key": "sessions_per_week", "category": "Sessions / Woche", "icon": "📅", "value": spw_s, "sublabel": f"Fenster: {summary.get('days_analyzed', '—')} Tage", "status": "good", "verdict": "Gut", "hoverTop": "Durchschnittliche Sessions pro Woche", "hoverBody": "Aus activity_summary (activity_log im gewählten Zeitraum).", "keys": ["activity_summary"], }, { "key": "quality_pct", "category": "Qualitätssessions", "icon": "✓", "value": f"{quality_pct} %" if quality_pct is not None else "—", "sublabel": f"Anteil «gut+» · {quality_window_days} Tage", "status": q_status, "verdict": _verdict(q_status), "hoverTop": "Anteil Sessions mit guter Qualitätslabel-Klassifikation", "hoverBody": "Entspricht quality_sessions_pct (Fenster wie gewählt).", "keys": ["quality_sessions_pct"], }, { "key": "activity_score", "category": "Activity-Score", "icon": "🎯", "value": str(activity_score) if activity_score is not None else "—", "sublabel": "Ausrichtung an gewichteten Fokusbereichen", "status": s_status, "verdict": _verdict(s_status) if activity_score is not None else "Hinweis", "hoverTop": "Gewichteter Score (0–100)", "hoverBody": "Ohne gewichtete Aktivitäts-Fokusbereiche kein Score.", "keys": ["activity_score"], }, { "key": "vo2_trend", "category": "VO₂max-Trend", "icon": "🫁", "value": f"{vo2_trend:+.1f}" if vo2_trend is not None else "—", "sublabel": "28-Tage-Trend (geschätzt)", "status": v_status, "verdict": _verdict(v_status) if vo2_trend is not None else "Hinweis", "hoverTop": "Trend der VO₂max-Schätzung aus Aktivitätsdaten", "hoverBody": "Wie vo2max_trend_28d im Data Layer.", "keys": ["vo2max_trend_28d"], }, ] ) if top_focus: prog = top_focus.get("progress") 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