- Bumped application version to 0.9r and updated build date to 2026-04-20. - Added a new endpoint `/activity-last-updated` to retrieve the last activity date for a user, optimizing data retrieval for activity history. - Updated the frontend to utilize the new endpoint, enhancing the ActivitySection with the last activity date display. - Refactored the History component to streamline data loading and improve user experience with activity insights.
158 lines
5.2 KiB
Python
158 lines
5.2 KiB
Python
"""
|
|
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_activity_last_updated_iso(profile_id: str) -> Optional[str]:
|
|
"""
|
|
Leichtgewicht: letztes activity_log.date — identisch zu ``last_updated`` im Fitness-Viz-Bundle.
|
|
|
|
Für History-Header o. Ä. ohne vollständige Aktivitätsliste (Phase A, Issue-53-Pfad).
|
|
"""
|
|
return _last_activity_date(profile_id)
|
|
|
|
|
|
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",
|
|
},
|
|
}
|