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))
|
samples = db.deferred(db.Column(db.JSON, nullable=False, default=list))
|
||||||
laps = 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))
|
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
|
@property
|
||||||
def distance_km(self):
|
def distance_km(self):
|
||||||
|
|||||||
+6
-2
@@ -85,14 +85,17 @@ def persist_efforts(activity):
|
|||||||
activity.has_route = any(sample.get("point") for sample in activity.samples)
|
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.
|
"""Compare persisted efforts in chronology; never read samples here.
|
||||||
|
|
||||||
First efforts establish a record. Strict improvements earn historical PR
|
First efforts establish a record. Strict improvements earn historical PR
|
||||||
badges, which remain on the run after a later activity beats that record.
|
badges, which remain on the run after a later activity beats that record.
|
||||||
"""
|
"""
|
||||||
records, runs = {}, {}
|
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 = (
|
efforts = (
|
||||||
[dict(effort) for effort in activity.best_efforts]
|
[dict(effort) for effort in activity.best_efforts]
|
||||||
if activity.efforts_version == EFFORTS_VERSION and activity.sport == "running"
|
if activity.efforts_version == EFFORTS_VERSION and activity.sport == "running"
|
||||||
@@ -103,6 +106,7 @@ def record_book(activities):
|
|||||||
effort["pr"] = previous is None or effort["seconds"] < previous["seconds"] - 1e-6
|
effort["pr"] = previous is None or effort["seconds"] < previous["seconds"] - 1e-6
|
||||||
if effort["pr"]:
|
if effort["pr"]:
|
||||||
records[effort["label"]] = dict(effort, activity=activity)
|
records[effort["label"]] = dict(effort, activity=activity)
|
||||||
|
if run_ids is None or activity.id in run_ids:
|
||||||
runs[activity.id] = efforts
|
runs[activity.id] = efforts
|
||||||
return {"records": [records[label] for label, _ in DISTANCES if label in records], "runs": runs}
|
return {"records": [records[label] for label, _ in DISTANCES if label in records], "runs": runs}
|
||||||
|
|
||||||
|
|||||||
+25
-58
@@ -1,10 +1,9 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from collections import defaultdict
|
from datetime import UTC, datetime
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo, available_timezones
|
from zoneinfo import available_timezones
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -27,8 +26,8 @@ from garmin_sync.service import SyncError, atomic_write
|
|||||||
from .analytics import pace_text
|
from .analytics import pace_text
|
||||||
from .display import stored_analysis
|
from .display import stored_analysis
|
||||||
from .extensions import db, limiter
|
from .extensions import db, limiter
|
||||||
|
from .history import activity_page, latest_mapped_run, overview, records_for
|
||||||
from .models import Activity, Friendship, GarminConnection, User
|
from .models import Activity, Friendship, GarminConnection, User
|
||||||
from .records import record_book
|
|
||||||
from .services import activity_data, import_cache, import_upload, route_data
|
from .services import activity_data, import_cache, import_upload, route_data
|
||||||
|
|
||||||
web = Blueprint("web", __name__)
|
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")
|
@web.get("/health")
|
||||||
def health():
|
def health():
|
||||||
db.session.execute(db.text("SELECT 1"))
|
db.session.execute(db.text("SELECT 1"))
|
||||||
@@ -208,17 +177,18 @@ def logout():
|
|||||||
@web.get("/dashboard")
|
@web.get("/dashboard")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
rows = db.session.scalars(
|
filters = [Activity.owner_id == current_user.id]
|
||||||
db.select(Activity).filter_by(owner_id=current_user.id).order_by(Activity.started_at.desc())
|
stats = overview(filters, current_user.timezone)
|
||||||
).all()
|
pagination = activity_page(filters, total=stats["count"])
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
rows=rows,
|
rows=pagination.items,
|
||||||
stats=overview(rows, current_user.timezone),
|
pagination=pagination,
|
||||||
|
stats=stats,
|
||||||
profile=current_user,
|
profile=current_user,
|
||||||
personal=True,
|
personal=True,
|
||||||
record_book=record_book(rows),
|
record_book=records_for(current_user.id, {row.id for row in pagination.items}),
|
||||||
mapped_run=latest_mapped_run(rows),
|
mapped_run=latest_mapped_run(filters),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -257,13 +227,12 @@ def friends():
|
|||||||
for link in links
|
for link in links
|
||||||
]
|
]
|
||||||
accepted_ids = [p["person"].id for p in people if p["link"].status == "accepted"]
|
accepted_ids = [p["person"].id for p in people if p["link"].status == "accepted"]
|
||||||
rows = db.session.scalars(
|
pagination = activity_page(
|
||||||
db.select(Activity)
|
[Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends"]
|
||||||
.where(Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends")
|
)
|
||||||
.order_by(Activity.started_at.desc())
|
return render_template(
|
||||||
.limit(50)
|
"friends.html", people=people, rows=pagination.items, pagination=pagination
|
||||||
).all()
|
)
|
||||||
return render_template("friends.html", people=people, rows=rows)
|
|
||||||
|
|
||||||
|
|
||||||
@web.post("/friends/<int:friendship_id>/<action>")
|
@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))
|
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):
|
if user.id != current_user.id and not are_friends(current_user.id, user.id):
|
||||||
abort(404)
|
abort(404)
|
||||||
rows = db.session.scalars(
|
filters = [Activity.owner_id == user.id, Activity.visibility == "friends"]
|
||||||
db.select(Activity)
|
stats = overview(filters, user.timezone)
|
||||||
.filter_by(owner_id=user.id, visibility="friends")
|
pagination = activity_page(filters, total=stats["count"])
|
||||||
.order_by(Activity.started_at.desc())
|
|
||||||
).all()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
rows=rows,
|
rows=pagination.items,
|
||||||
stats=overview(rows, user.timezone),
|
pagination=pagination,
|
||||||
|
stats=stats,
|
||||||
profile=user,
|
profile=user,
|
||||||
personal=False,
|
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)
|
analysis = stored_analysis(row)
|
||||||
efforts = []
|
efforts = []
|
||||||
if owner:
|
if owner:
|
||||||
history = db.session.scalars(db.select(Activity).filter_by(owner_id=row.owner_id)).all()
|
efforts = records_for(row.owner_id, {row.id})["runs"].get(row.id, [])
|
||||||
efforts = record_book(history)["runs"].get(row.id, [])
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"activity.html",
|
"activity.html",
|
||||||
activity=row,
|
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 { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
||||||
.run-badges:empty { display: none; }
|
.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; }
|
.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 %}
|
{% 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>
|
</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 %}
|
{% 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>
|
<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>
|
<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 %}
|
{% 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 %}
|
{% 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 %}
|
{% 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 %}
|
{% 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 %}
|
<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></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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -63,3 +63,30 @@ HTTP 200 and `status: ok`; Dokku web and sync worker startup checks succeeded.
|
|||||||
The synthetic browser fixture verified dashboard replay, route colour switching
|
The synthetic browser fixture verified dashboard replay, route colour switching
|
||||||
and charts. Its in-memory SQLite server uses one request thread to avoid concurrent
|
and charts. Its in-memory SQLite server uses one request thread to avoid concurrent
|
||||||
access to the shared SQLite connection.
|
access to the shared SQLite connection.
|
||||||
|
|
||||||
|
## Paginated history
|
||||||
|
|
||||||
|
Dashboard, shared profiles and the friends feed show 25 activities per page,
|
||||||
|
ordered by start time descending with activity ID as the tie-breaker. Navigation
|
||||||
|
preserves profile URLs and returns to the history section. Invalid or empty
|
||||||
|
out-of-range pages return 404; an empty first page remains valid.
|
||||||
|
|
||||||
|
Dashboard and profile totals are SQL aggregates over the entire authorized
|
||||||
|
history. Eight weekly buckets use independent local-time Monday boundaries
|
||||||
|
converted to UTC, including daylight-saving changes. The latest GPS run is an
|
||||||
|
independent indexed query, so paging does not change the map. Friend/profile
|
||||||
|
filters apply equally to pagination, counts, totals and route selection.
|
||||||
|
|
||||||
|
Personal record comparisons stream only ID, title, timestamp, sport, calculation
|
||||||
|
version and stored efforts in batches of 1,000. They retain at most six current
|
||||||
|
record holders and effort details for the requested page (or one run's detail).
|
||||||
|
No full-history Activity ORM objects, raw samples, charts or laps are loaded.
|
||||||
|
Comparisons remain linear in the number of recorded runs; page rows and ORM
|
||||||
|
object counts are bounded. Owners are loaded with the page to avoid per-row
|
||||||
|
athlete queries. Composite indexes support owner/date and shared owner/date lists.
|
||||||
|
|
||||||
|
A disposable local SQLite benchmark with 10,000 synthetic activities returned
|
||||||
|
25 rows and 26 ORM objects on pages 1, 200 and 400. HTML remained about 30 KiB;
|
||||||
|
measured request times were 148, 122 and 97 ms respectively. These are synthetic
|
||||||
|
local measurements, not production latency guarantees. Browser checks with 55
|
||||||
|
synthetic activities verified pages 1–3, including the five-row final page.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Index athlete activity history and shared profile pagination."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "09a7c863b521"
|
||||||
|
down_revision = "f08db2902a74"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_index("ix_activity_owner_started_id", "activity", ["owner_id", "started_at", "id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_activity_owner_visibility_started_id",
|
||||||
|
"activity",
|
||||||
|
["owner_id", "visibility", "started_at", "id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_activity_owner_visibility_started_id", table_name="activity")
|
||||||
|
op.drop_index("ix_activity_owner_started_id", table_name="activity")
|
||||||
+165
-1
@@ -62,9 +62,173 @@ def app(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def login(client, name="runner"):
|
def login(client, name="runner"):
|
||||||
return client.post(
|
response = client.post(
|
||||||
"/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"}
|
"/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"}
|
||||||
)
|
)
|
||||||
|
assert response.status_code == 302, response.data.decode()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def add_history(count, visibility="private"):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
for index in range(count):
|
||||||
|
db.session.add(
|
||||||
|
Activity(
|
||||||
|
owner_id=1,
|
||||||
|
source_key=f"history:{index}",
|
||||||
|
title=f"History run {index:03d}",
|
||||||
|
sport="running",
|
||||||
|
started_at=datetime(2026, 9, 1, tzinfo=UTC) + timedelta(days=index),
|
||||||
|
distance_m=1000,
|
||||||
|
duration_s=300,
|
||||||
|
elevation_m=1,
|
||||||
|
visibility=visibility,
|
||||||
|
efforts_version=EFFORTS_VERSION,
|
||||||
|
best_efforts=[
|
||||||
|
{
|
||||||
|
"label": "1 km",
|
||||||
|
"distance": 1000,
|
||||||
|
"seconds": 250 if index == 0 else 300,
|
||||||
|
"start_m": 0,
|
||||||
|
"end_m": 1000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
samples=[],
|
||||||
|
laps=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_pages_keep_full_totals_records_and_latest_route(app):
|
||||||
|
import re
|
||||||
|
|
||||||
|
from flask import template_rendered
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
add_history(60)
|
||||||
|
contexts = []
|
||||||
|
|
||||||
|
def capture(_app, template, context, **_extra):
|
||||||
|
contexts.append(context)
|
||||||
|
|
||||||
|
template_rendered.connect(capture, app)
|
||||||
|
client = app.test_client()
|
||||||
|
login(client)
|
||||||
|
try:
|
||||||
|
seen = []
|
||||||
|
for page, size in ((1, 25), (2, 25), (3, 11)):
|
||||||
|
response = client.get(f"/dashboard?page={page}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
context = contexts[-1]
|
||||||
|
assert len(context["rows"]) == size
|
||||||
|
assert context["stats"]["count"] == 61
|
||||||
|
assert context["stats"]["distance"] == 65
|
||||||
|
assert context["stats"]["duration"] == 19800
|
||||||
|
assert context["stats"]["elevation"] == 100
|
||||||
|
assert context["mapped_run"].id == 1
|
||||||
|
assert context["record_book"]["records"][0]["seconds"] == 250
|
||||||
|
assert set(context["record_book"]["runs"]).issubset({row.id for row in context["rows"]})
|
||||||
|
seen.extend(
|
||||||
|
int(i)
|
||||||
|
for i in re.findall(
|
||||||
|
rb'class="activity-name" href="/activities/(\d+)"', response.data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert len(seen) == len(set(seen)) == 61
|
||||||
|
assert b"Showing 51" in response.data
|
||||||
|
assert b"1 km PR" in response.data
|
||||||
|
assert client.get("/dashboard?page=4").status_code == 404
|
||||||
|
for value in ("0", "-1", "nope", "999999999999999999999", "1.5"):
|
||||||
|
assert client.get(f"/dashboard?page={value}").status_code == 404
|
||||||
|
finally:
|
||||||
|
template_rendered.disconnect(capture, app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagination_uses_id_to_break_equal_timestamp_ties(app):
|
||||||
|
with app.app_context():
|
||||||
|
add_history(50)
|
||||||
|
db.session.execute(db.update(Activity).values(started_at=datetime(2026, 1, 1, tzinfo=UTC)))
|
||||||
|
db.session.commit()
|
||||||
|
import re
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
login(client)
|
||||||
|
ids = []
|
||||||
|
for page in (1, 2, 3):
|
||||||
|
ids.extend(
|
||||||
|
int(i)
|
||||||
|
for i in re.findall(
|
||||||
|
rb'class="activity-name" href="/activities/(\d+)"',
|
||||||
|
client.get(f"/dashboard?page={page}").data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert ids == list(reversed(range(1, 52)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_and_friends_pages_include_only_visible_history(app):
|
||||||
|
from flask import template_rendered
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
add_history(53, visibility="friends")
|
||||||
|
db.session.add(Friendship(low_id=1, high_id=2, requested_by=1, status="accepted"))
|
||||||
|
db.session.commit()
|
||||||
|
contexts = []
|
||||||
|
|
||||||
|
def capture(_app, template, context, **_extra):
|
||||||
|
contexts.append(context)
|
||||||
|
|
||||||
|
template_rendered.connect(capture, app)
|
||||||
|
client = app.test_client()
|
||||||
|
login(client, "other")
|
||||||
|
try:
|
||||||
|
for path in ("/u/runner", "/friends"):
|
||||||
|
for page, expected in ((1, 25), (2, 25), (3, 3)):
|
||||||
|
response = client.get(f"{path}?page={page}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(contexts[-1]["rows"]) == expected
|
||||||
|
assert contexts[-1]["pagination"].total == 53
|
||||||
|
assert b"A quiet morning run" not in response.data
|
||||||
|
if path == "/u/runner":
|
||||||
|
assert contexts[-1]["stats"]["distance"] == 53
|
||||||
|
client.post("/friends/1/remove")
|
||||||
|
assert client.get("/u/runner?page=2").status_code == 404
|
||||||
|
assert client.get("/friends?page=2").status_code == 404
|
||||||
|
finally:
|
||||||
|
template_rendered.disconnect(capture, app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_sql_totals_respect_sydney_dst_boundaries(app):
|
||||||
|
from app.history import overview
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
for index, (started, distance) in enumerate(
|
||||||
|
(
|
||||||
|
(datetime(2026, 10, 4, 12, 59, 59, tzinfo=UTC), 1000),
|
||||||
|
(datetime(2026, 10, 4, 13, 0, 0, tzinfo=UTC), 2000),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
db.session.add(
|
||||||
|
Activity(
|
||||||
|
owner_id=2,
|
||||||
|
source_key=f"dst:{index}",
|
||||||
|
title="DST run",
|
||||||
|
started_at=started,
|
||||||
|
distance_m=distance,
|
||||||
|
duration_s=600,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
stats = overview([Activity.owner_id == 2], "Australia/Sydney")
|
||||||
|
assert stats["weeks"][-2:] == ["28 Sep", "05 Oct"]
|
||||||
|
assert stats["weekly_km"][-2:] == [1, 2]
|
||||||
|
assert stats["distance"] == 3
|
||||||
|
assert stats["count"] == 2
|
||||||
|
empty = overview([Activity.owner_id == 999], "Australia/Sydney")
|
||||||
|
assert empty["distance"] == 0
|
||||||
|
assert empty["pace"] == "—"
|
||||||
|
assert empty["weekly_km"] == [0] * 8
|
||||||
|
|
||||||
|
|
||||||
def test_private_activity_authorization_for_pages_json_and_mutations(app):
|
def test_private_activity_authorization_for_pages_json_and_mutations(app):
|
||||||
|
|||||||
Reference in New Issue
Block a user