2026-04-19 21:27:12 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 08:04:50 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 21:27:12 +02:00
|
|
|
|
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]],
|
2026-04-20 08:04:50 +02:00
|
|
|
|
vol_delta: Optional[Dict[str, Any]] = None,
|
2026-04-19 21:27:12 +02:00
|
|
|
|
) -> 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)
|
|
|
|
|
|
|
2026-04-20 08:04:50 +02:00
|
|
|
|
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"],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-04-19 21:27:12 +02:00
|
|
|
|
|
|
|
|
|
|
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
|