Paginate activity history and aggregate dashboard totals in SQL
This commit is contained in:
+118
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user