"""Bounded activity lists, SQL totals and small streamed record comparisons.""" from datetime import UTC, datetime, timedelta from itertools import pairwise from zoneinfo import ZoneInfo from flask import abort, request from sqlalchemy.orm import joinedload from .extensions import db from .models import Activity from .records import EFFORTS_VERSION, record_book PAGE_SIZE = 25 def activity_page(filters, total=None): value = request.args.get("page", "1") if not value.isascii() or not value.isdecimal() or len(value) > 10 or int(value) < 1: abort(404) pagination = db.paginate( db.select(Activity) .where(*filters) .options(joinedload(Activity.owner)) .order_by(Activity.started_at.desc(), Activity.id.desc()), page=int(value), per_page=PAGE_SIZE, max_per_page=PAGE_SIZE, count=total is None, error_out=True, ) if total is not None: pagination.total = total return pagination def overview(filters, timezone): totals = db.session.execute( db.select( db.func.count(Activity.id), db.func.sum(Activity.distance_m), db.func.sum(Activity.duration_s), db.func.sum(Activity.elevation_m), db.func.max(Activity.started_at), ).where(*filters) ).one() count, distance, duration, elevation, latest = totals distance, duration, elevation = distance or 0, duration or 0, elevation or 0 zone = ZoneInfo(timezone) latest_day = (latest or datetime.now(UTC)).replace(tzinfo=UTC).astimezone(zone).date() monday = latest_day - timedelta(days=latest_day.weekday()) weeks = [monday - timedelta(weeks=n) for n in reversed(range(8))] # Convert each local week boundary independently, including daylight-saving changes. boundaries = [ datetime.combine(day, datetime.min.time(), tzinfo=zone).astimezone(UTC) for day in [*weeks, monday + timedelta(weeks=1)] ] weekly = db.session.execute( db.select( *[ db.func.coalesce( db.func.sum( db.case( ( db.and_(Activity.started_at >= start, Activity.started_at < end), Activity.distance_m, ), else_=0, ) ), 0, ) for start, end in pairwise(boundaries) ] ).where( *filters, Activity.started_at >= boundaries[0], Activity.started_at < boundaries[-1] ) ).one() seconds = round(duration * 1000 / distance) if distance else None return { "distance": distance / 1000, "duration": duration, "count": count, "elevation": elevation, "pace": f"{seconds // 60}:{seconds % 60:02d}" if seconds is not None else "—", "weeks": [day.strftime("%d %b") for day in weeks], "weekly_km": [round(value / 1000, 2) for value in weekly], } def latest_mapped_run(filters): return db.session.scalar( db.select(Activity) .where(*filters, Activity.sport == "running", Activity.has_route.is_(True)) .order_by(Activity.started_at.desc(), Activity.id.desc()) .limit(1) ) def records_for(owner_id, run_ids, shared_only=False): visibility_filters = [Activity.visibility == "friends"] if shared_only else [] history = db.session.execute( db.select( Activity.id, Activity.title, Activity.started_at, Activity.sport, Activity.efforts_version, Activity.best_efforts, ) .where( *visibility_filters, Activity.owner_id == owner_id, Activity.sport == "running", Activity.efforts_version == EFFORTS_VERSION, ) .order_by(Activity.started_at, Activity.id) .execution_options(yield_per=1000) ) return record_book(history, run_ids=run_ids, presorted=True)