feat: Activities filters (type/year/date-range/distance) + Summary page with all-time & per-year/per-sport totals and distance-per-year chart
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s

This commit is contained in:
2026-06-21 16:29:33 +01:00
parent 69ecdaa4b2
commit e2870e86a8
5 changed files with 344 additions and 74 deletions
+111 -8
View File
@@ -104,6 +104,110 @@ async def ytd_stats(
}
def _apply_activity_filters(q, *, sport_type, from_date, to_date, year,
min_distance_km, max_distance_km):
"""Apply the shared Activities-list filters to a query selecting Activity."""
from datetime import timezone
if sport_type:
q = q.where(Activity.sport_type == sport_type)
if year:
ys = datetime(year, 1, 1, tzinfo=timezone.utc)
ye = datetime(year + 1, 1, 1, tzinfo=timezone.utc)
q = q.where(Activity.start_time >= ys, Activity.start_time < ye)
if from_date:
q = q.where(Activity.start_time >= from_date)
if to_date:
q = q.where(Activity.start_time <= to_date)
if min_distance_km is not None:
q = q.where(Activity.distance_m >= min_distance_km * 1000)
if max_distance_km is not None:
q = q.where(Activity.distance_m <= max_distance_km * 1000)
return q
@router.get("/stats/filters")
async def filter_options(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Distinct sport types and activity years for this user — drives the
Activities page filter controls."""
from datetime import timezone
yr = func.extract("year", Activity.start_time)
sports = (await db.execute(
select(Activity.sport_type)
.where(Activity.user_id == current_user.id)
.distinct().order_by(Activity.sport_type)
)).scalars().all()
years = (await db.execute(
select(yr.label("yr"))
.where(Activity.user_id == current_user.id)
.distinct().order_by(yr.desc())
)).scalars().all()
return {
"sport_types": [s for s in sports if s],
"years": [int(y) for y in years if y is not None],
}
@router.get("/stats/summary")
async def stats_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Per-year and per-sport totals (count, distance, time, elevation) plus an
all-time roll-up. Powers the Summary page."""
yr = func.extract("year", Activity.start_time)
rows = (await db.execute(
select(
yr.label("yr"),
Activity.sport_type,
func.count().label("cnt"),
func.coalesce(func.sum(Activity.distance_m), 0).label("dist"),
func.coalesce(func.sum(func.coalesce(Activity.moving_time_s, Activity.duration_s)), 0).label("dur"),
func.coalesce(func.sum(Activity.elevation_gain_m), 0).label("elev"),
)
.where(Activity.user_id == current_user.id)
.group_by(yr, Activity.sport_type)
)).all()
years: dict[int, dict] = {}
for r in rows:
if r.yr is None:
continue
yr = int(r.yr)
y = years.setdefault(yr, {"year": yr, "count": 0, "distance_km": 0.0,
"duration_s": 0.0, "elevation_m": 0.0, "by_sport": []})
sport = {
"sport_type": r.sport_type,
"count": int(r.cnt),
"distance_km": round((r.dist or 0) / 1000, 2),
"duration_s": float(r.dur or 0),
"elevation_m": round(r.elev or 0, 1),
}
y["by_sport"].append(sport)
y["count"] += sport["count"]
y["distance_km"] += sport["distance_km"]
y["duration_s"] += sport["duration_s"]
y["elevation_m"] += sport["elevation_m"]
by_year = []
for y in sorted(years.values(), key=lambda x: x["year"], reverse=True):
y["by_sport"].sort(key=lambda s: s["distance_km"], reverse=True)
y["distance_km"] = round(y["distance_km"], 2)
y["elevation_m"] = round(y["elevation_m"], 1)
by_year.append(y)
all_time = {
"count": sum(y["count"] for y in by_year),
"distance_km": round(sum(y["distance_km"] for y in by_year), 2),
"duration_s": sum(y["duration_s"] for y in by_year),
"elevation_m": round(sum(y["elevation_m"] for y in by_year), 1),
}
sport_types = sorted({s["sport_type"] for y in by_year for s in y["by_sport"] if s["sport_type"]})
return {"all_time": all_time, "by_year": by_year, "sport_types": sport_types}
@router.get("/", response_model=List[ActivitySummary])
async def list_activities(
page: int = Query(1, ge=1),
@@ -111,18 +215,17 @@ async def list_activities(
sport_type: Optional[str] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
year: Optional[int] = None,
min_distance_km: Optional[float] = None,
max_distance_km: Optional[float] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
if from_date:
q = q.where(Activity.start_time >= from_date)
if to_date:
q = q.where(Activity.start_time <= to_date)
q = _apply_activity_filters(
q, sport_type=sport_type, from_date=from_date, to_date=to_date, year=year,
min_distance_km=min_distance_km, max_distance_km=max_distance_km,
)
q = q.order_by(desc(Activity.start_time))
q = q.offset((page - 1) * per_page).limit(per_page)