feat: Activities filters (type/year/date-range/distance) + Summary page with all-time & per-year/per-sport totals and distance-per-year chart
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user