Paginate activity history and aggregate dashboard totals in SQL

This commit is contained in:
Peter Stockings
2026-10-02 14:36:35 +10:00
parent a300ea007d
commit 5830976e31
11 changed files with 386 additions and 65 deletions
+118
View File
@@ -0,0 +1,118 @@
"""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):
history = db.session.execute(
db.select(
Activity.id,
Activity.title,
Activity.started_at,
Activity.sport,
Activity.efforts_version,
Activity.best_efforts,
)
.where(
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)
+7 -1
View File
@@ -70,7 +70,13 @@ class Activity(db.Model):
samples = db.deferred(db.Column(db.JSON, nullable=False, default=list))
laps = db.deferred(db.Column(db.JSON, nullable=False, default=list))
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
__table_args__ = (db.UniqueConstraint("owner_id", "source_key"),)
__table_args__ = (
db.UniqueConstraint("owner_id", "source_key"),
db.Index("ix_activity_owner_started_id", "owner_id", "started_at", "id"),
db.Index(
"ix_activity_owner_visibility_started_id", "owner_id", "visibility", "started_at", "id"
),
)
@property
def distance_km(self):
+7 -3
View File
@@ -85,14 +85,17 @@ def persist_efforts(activity):
activity.has_route = any(sample.get("point") for sample in activity.samples)
def record_book(activities):
def record_book(activities, run_ids=None, presorted=False):
"""Compare persisted efforts in chronology; never read samples here.
First efforts establish a record. Strict improvements earn historical PR
badges, which remain on the run after a later activity beats that record.
"""
records, runs = {}, {}
for activity in sorted(activities, key=lambda row: (row.started_at, row.id)):
history = (
activities if presorted else sorted(activities, key=lambda row: (row.started_at, row.id))
)
for activity in history:
efforts = (
[dict(effort) for effort in activity.best_efforts]
if activity.efforts_version == EFFORTS_VERSION and activity.sport == "running"
@@ -103,7 +106,8 @@ def record_book(activities):
effort["pr"] = previous is None or effort["seconds"] < previous["seconds"] - 1e-6
if effort["pr"]:
records[effort["label"]] = dict(effort, activity=activity)
runs[activity.id] = efforts
if run_ids is None or activity.id in run_ids:
runs[activity.id] = efforts
return {"records": [records[label] for label, _ in DISTANCES if label in records], "runs": runs}
+25 -58
View File
@@ -1,10 +1,9 @@
import hashlib
import re
import secrets
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from pathlib import Path
from zoneinfo import ZoneInfo, available_timezones
from zoneinfo import available_timezones
from flask import (
Blueprint,
@@ -27,8 +26,8 @@ from garmin_sync.service import SyncError, atomic_write
from .analytics import pace_text
from .display import stored_analysis
from .extensions import db, limiter
from .history import activity_page, latest_mapped_run, overview, records_for
from .models import Activity, Friendship, GarminConnection, User
from .records import record_book
from .services import activity_data, import_cache, import_upload, route_data
web = Blueprint("web", __name__)
@@ -75,36 +74,6 @@ def are_friends(first, second):
)
def latest_mapped_run(rows):
return next(
(row for row in rows if row.sport == "running" and row.has_route),
None,
)
def overview(rows, timezone="Australia/Sydney"):
total_distance = sum(r.distance_m for r in rows)
total_time = sum(r.duration_s for r in rows)
latest = max((r.started_at for r in rows), default=datetime.now(UTC))
latest_day = latest.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
monday = latest_day - timedelta(days=latest_day.weekday())
weeks = [monday - timedelta(weeks=n) for n in reversed(range(8))]
distances = defaultdict(float)
for row in rows:
day = row.started_at.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
distances[day - timedelta(days=day.weekday())] += row.distance_km
seconds = round(total_time / (total_distance / 1000)) if total_distance else None
return {
"distance": total_distance / 1000,
"duration": total_time,
"count": len(rows),
"elevation": sum(r.elevation_m for r in rows),
"pace": f"{seconds // 60}:{seconds % 60:02d}" if seconds is not None else "—",
"weeks": [w.strftime("%d %b") for w in weeks],
"weekly_km": [round(distances[w], 2) for w in weeks],
}
@web.get("/health")
def health():
db.session.execute(db.text("SELECT 1"))
@@ -208,17 +177,18 @@ def logout():
@web.get("/dashboard")
@login_required
def dashboard():
rows = db.session.scalars(
db.select(Activity).filter_by(owner_id=current_user.id).order_by(Activity.started_at.desc())
).all()
filters = [Activity.owner_id == current_user.id]
stats = overview(filters, current_user.timezone)
pagination = activity_page(filters, total=stats["count"])
return render_template(
"dashboard.html",
rows=rows,
stats=overview(rows, current_user.timezone),
rows=pagination.items,
pagination=pagination,
stats=stats,
profile=current_user,
personal=True,
record_book=record_book(rows),
mapped_run=latest_mapped_run(rows),
record_book=records_for(current_user.id, {row.id for row in pagination.items}),
mapped_run=latest_mapped_run(filters),
)
@@ -257,13 +227,12 @@ def friends():
for link in links
]
accepted_ids = [p["person"].id for p in people if p["link"].status == "accepted"]
rows = db.session.scalars(
db.select(Activity)
.where(Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends")
.order_by(Activity.started_at.desc())
.limit(50)
).all()
return render_template("friends.html", people=people, rows=rows)
pagination = activity_page(
[Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends"]
)
return render_template(
"friends.html", people=people, rows=pagination.items, pagination=pagination
)
@web.post("/friends/<int:friendship_id>/<action>")
@@ -290,18 +259,17 @@ def profile(username):
user = db.first_or_404(db.select(User).filter_by(username=username))
if user.id != current_user.id and not are_friends(current_user.id, user.id):
abort(404)
rows = db.session.scalars(
db.select(Activity)
.filter_by(owner_id=user.id, visibility="friends")
.order_by(Activity.started_at.desc())
).all()
filters = [Activity.owner_id == user.id, Activity.visibility == "friends"]
stats = overview(filters, user.timezone)
pagination = activity_page(filters, total=stats["count"])
return render_template(
"dashboard.html",
rows=rows,
stats=overview(rows, user.timezone),
rows=pagination.items,
pagination=pagination,
stats=stats,
profile=user,
personal=False,
mapped_run=latest_mapped_run(rows),
mapped_run=latest_mapped_run(filters),
)
@@ -311,8 +279,7 @@ def activity(activity_id):
analysis = stored_analysis(row)
efforts = []
if owner:
history = db.session.scalars(db.select(Activity).filter_by(owner_id=row.owner_id)).all()
efforts = record_book(history)["runs"].get(row.id, [])
efforts = records_for(row.owner_id, {row.id})["runs"].get(row.id, [])
return render_template(
"activity.html",
activity=row,
+4
View File
@@ -35,3 +35,7 @@ body{font-family:"Segoe UI",Arial,sans-serif}h1{font-weight:650;letter-spacing:-
.run-badges { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.run-badges:empty { display: none; }
.pr-badge { display: inline-block; border: 1px solid #886b34; border-radius: 4px; padding: 3px 6px; color: #f3cb78; background: #3a3020; font-size: 11px; font-weight: 600; }
.history-pagination { display:flex; align-items:center; justify-content:space-between; gap:16px; padding-top:20px; border-top:1px solid var(--line); }
.history-pagination > div { display:flex; align-items:center; gap:12px; }
@media(max-width:680px) { .history-pagination { flex-direction:column; align-items:flex-start; } .history-pagination > div { flex-wrap:wrap; } }
+9
View File
@@ -3,3 +3,12 @@
{% for row in rows %}<tr><td><a class="activity-name" href="{{ url_for('web.activity',activity_id=row.id) }}"><span class="sport-icon">{{ icon('run') }}</span><span><strong>{{ row.title }}</strong>{% if record_book is defined %}<span class="run-badges">{% for effort in record_book.runs.get(row.id, []) if effort.pr %}<span class="pr-badge" title="Personal record set on this run · {{ effort.seconds|duration }}">★ {{ effort.label }} PR</span>{% endfor %}</span>{% endif %}<small>{{ row.started_at|localdate(row.owner.timezone) }} <span>·</span> {{ row.sport|replace('_',' ')|title }}</small></span></a></td><td><strong>{{ '%.2f'|format(row.distance_km) }}</strong><small> km</small></td><td>{{ row.duration_s|duration }}</td><td>{{ row.pace }}<small> /km</small></td><td>{% if personal|default(false) %}<span class="pill {{ row.visibility }}">{{ row.visibility|title }}</span>{% else %}<a class="athlete-link" href="{{ url_for('web.profile',username=row.owner.username) }}">{{ row.owner.display_name }}</a>{% endif %}</td><td><a class="row-arrow" aria-label="View {{ row.title }}" href="{{ url_for('web.activity',activity_id=row.id) }}">{{ icon('arrow') }}</a></td></tr>{% endfor %}
</tbody></table></div>
{% else %}<div class="empty-state"><span class="empty-symbol">↗</span><h3>No activities yet.</h3><p>{{ 'Import a FIT file or load your Garmin activities to get started.' if personal|default(false) else 'No shared activities just yet. Check back after the next run.' }}</p>{% if personal|default(false) %}<a class="button primary" href="{{ url_for('web.settings') }}">Import your first run {{ icon('arrow') }}</a>{% endif %}</div>{% endif %}
{% if pagination is defined and pagination.pages > 1 %}
<nav class="history-pagination" aria-label="Activity history pages">
<span class="field-help">Showing {{ pagination.first }}–{{ pagination.last }} of {{ pagination.total }} activities</span>
<div>{% if pagination.has_prev %}<a class="button secondary" rel="prev" href="{{ url_for(request.endpoint, page=pagination.prev_num, **(request.view_args or {})) }}#history">← Newer</a>{% endif %}
<span class="field-help">Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}<a class="button secondary" rel="next" href="{{ url_for(request.endpoint, page=pagination.next_num, **(request.view_args or {})) }}#history">Older →</a>{% endif %}</div>
</nav>
{% endif %}
+1 -1
View File
@@ -25,6 +25,6 @@
<p class="section-description">Fastest stretches within your runs, including stops. PR badges mark records set at the time of the run; your first effort establishes a record.</p>
<div class="records-grid">{% for record in record_book.records %}<a class="record-card" href="{{ url_for('web.activity', activity_id=record.activity.id) }}"><span>{{ record.label }}</span><strong>{{ record.seconds|duration }}</strong><small>{{ record.activity.started_at|localdate(profile.timezone) }} · {{ record.activity.title }}</small></a>{% else %}<p class="field-help">Import a run with recorded distance and timestamps to establish your first record.</p>{% endfor %}</div></section>
{% endif %}
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">SESSION HISTORY</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ rows|length }} activities</span></div>{% include '_activities.html' %}</section>
<section class="panel activities-panel" id="history"><div class="section-head"><div><span class="eyebrow">SESSION HISTORY</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ stats.count }} activities</span></div>{% include '_activities.html' %}</section>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
+1 -1
View File
@@ -10,5 +10,5 @@
{% if entry.link.status == 'pending' and entry.link.requested_by != current_user.id %}<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='accept') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Accept</button></form>{% endif %}
<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='remove') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">{{ 'Remove' if entry.link.status == 'accepted' else 'Cancel' if entry.link.requested_by == current_user.id else 'Decline' }}</button></form></div></div>{% else %}<p class="muted">Your circle starts here. Add a friend using the email they registered with.</p>{% endfor %}
</section></div>
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">MILES FROM YOUR CIRCLE</span><h2>Friends’ activities</h2></div><span class="subtle-label">Shared with you</span></div>{% include '_activities.html' %}</section>
<section class="panel activities-panel" id="history"><div class="section-head"><div><span class="eyebrow">MILES FROM YOUR CIRCLE</span><h2>Friends’ activities</h2></div><span class="subtle-label">Shared with you</span></div>{% include '_activities.html' %}</section>
{% endblock %}