Add running personal records and historical PR badges

This commit is contained in:
Peter Stockings
2026-10-02 13:52:27 +10:00
parent b902ec6312
commit a82135bc89
10 changed files with 240 additions and 9 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Estimated distance best efforts from full-resolution recorded samples."""
from bisect import bisect_left, bisect_right
from .analytics import numeric
DISTANCES = (
("1 km", 1000),
("2 km", 2000),
("5 km", 5000),
("10 km", 10000),
("Half marathon", 21097.5),
("Marathon", 42195),
)
def best_efforts(activity):
"""Search piecewise-linear distance/time curves, counting internal stops.
A minimum starts or ends at a recorded distance breakpoint. Evaluate both
sets, interpolating the opposite boundary. At stationary boundaries use
latest departure and earliest arrival, excluding stops outside the effort.
Missing samples, distance resets and non-increasing times break the curve.
Timestamp gaps remain elapsed time, including pauses in watch recording.
"""
if activity.sport != "running":
return []
segments, segment = [], []
for sample in activity.samples:
distance, time = sample.get("distance"), sample.get("time")
if not numeric(distance) or not numeric(time) or distance < 0:
if segment:
segments.append(segment)
segment = []
continue
if segment and (distance < segment[-1][0] or time <= segment[-1][1]):
segments.append(segment)
segment = []
segment.append((distance, time))
if segment:
segments.append(segment)
best = {}
for segment in segments:
distances = [point[0] for point in segment]
times = [point[1] for point in segment]
def at(distance, departure=False, distances=distances, times=times):
index = (
bisect_right(distances, distance) - 1
if departure
else bisect_left(distances, distance)
)
if distances[index] == distance:
return times[index]
if departure:
index += 1
fraction = (distance - distances[index - 1]) / (distances[index] - distances[index - 1])
return times[index - 1] + fraction * (times[index] - times[index - 1])
for label, length in DISTANCES:
if distances[-1] - distances[0] < length:
continue
starts = {d for d in distances if d + length <= distances[-1]}
starts.update(d - length for d in distances if d - length >= distances[0])
for start in sorted(starts):
seconds = at(start + length) - at(start, departure=True)
if seconds > 0 and (label not in best or seconds < best[label]["seconds"]):
best[label] = {
"label": label,
"distance": length,
"seconds": seconds,
"start_m": start,
"end_m": start + length,
}
return [best[label] for label, _ in DISTANCES if label in best]
def record_book(activities):
"""Recompute in activity chronology so backfills and removals stay correct.
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)):
efforts = best_efforts(activity)
for effort in efforts:
previous = records.get(effort["label"])
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
return {"records": [records[label] for label, _ in DISTANCES if label in records], "runs": runs}
+12 -1
View File
@@ -27,6 +27,7 @@ from garmin_sync.service import SyncError, atomic_write
from .analytics import analyse, pace_text
from .extensions import db, limiter
from .models import Activity, Friendship, GarminConnection, User
from .records import record_book
from .services import activity_data, import_cache, import_upload
web = Blueprint("web", __name__)
@@ -219,6 +220,7 @@ def dashboard():
stats=overview(rows, current_user.timezone),
profile=current_user,
personal=True,
record_book=record_book(rows),
mapped_run=latest_mapped_run(rows),
)
@@ -310,8 +312,17 @@ def profile(username):
def activity(activity_id):
row, owner = get_activity(activity_id)
analysis = analyse(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, [])
return render_template(
"activity.html", activity=row, owner_view=owner, analysis=analysis, pace_text=pace_text
"activity.html",
activity=row,
owner_view=owner,
analysis=analysis,
pace_text=pace_text,
efforts=efforts,
)
+1 -5
View File
@@ -13,7 +13,6 @@ async function showDashboardRoute() {
const container = document.getElementById("dashboard-route-map");
if (!container?.dataset.url) return;
const status = document.getElementById("dashboard-route-status");
const toggle = document.getElementById("toggle-route-replay");
try {
const response = await fetch(container.dataset.url);
if (!response.ok) throw new Error("Route unavailable");
@@ -57,12 +56,9 @@ async function showDashboardRoute() {
const update = () => {
if (frame !== null) cancelAnimationFrame(frame);
frame = null; previous = null;
toggle.textContent = paused ? "Play replay" : "Pause replay";
status.textContent = paused ? "Replay paused. Explore the recorded route." : "Following the recorded route · 20-second replay";
status.hidden = true;
if (!paused && !document.hidden) frame = requestAnimationFrame(tick);
};
toggle.hidden = false;
toggle.onclick = () => { paused = !paused; update(); };
preference.addEventListener("change", event => { paused = event.matches; update(); });
document.addEventListener("visibilitychange", update);
update();
+10
View File
@@ -25,3 +25,13 @@ body{font-family:"Segoe UI",Arial,sans-serif}h1{font-weight:650;letter-spacing:-
.public-shell .landing-map{display:block;max-width:none;margin-top:26px}.public-shell .landing-map .running-map{height:210px}.public-shell .landing-features{flex-direction:row;flex-wrap:wrap;gap:16px;margin-top:22px;font-size:11px}@media(max-width:680px){.public-shell .landing-map .running-map{height:175px}}
/* Keep account controls at the bottom without a decorative sidebar map. */
.sidebar .sidebar-bottom{margin-top:auto}
.records-panel { margin-bottom: 24px; }
.records-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
.record-card { display: flex; flex-direction: column; gap: 8px; padding: 16px; border: 1px solid #394246; border-radius: 8px; color: inherit; text-decoration: none; }
.record-card > span { color: #a0aaac; font-size: 13px; }
.record-card > strong { font-size: 27px; }
.record-card > small { color: #a0aaac; overflow-wrap: anywhere; }
.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; }
+1 -1
View File
@@ -1,5 +1,5 @@
{% if rows %}
<div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>ACTIVITY</th><th>DISTANCE</th><th>TIME</th><th>AVG. PACE</th><th>{{ 'VISIBILITY' if personal|default(false) else 'ATHLETE' }}</th><th><span class="sr-only">Open</span></th></tr></thead><tbody>
{% 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><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>
{% 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 %}
+3
View File
@@ -9,6 +9,9 @@
{% if not public_view %}<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>{% endif %}
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By {% if public_view %}{{ activity.owner.display_name }}{% else %}<a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a>{% endif %}<span class="dot-separator">·</span>{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}<span class="dot-separator">·</span>{{ '{:,}'.format(analysis.summary.samples) }} recorded samples</p></div><div class="activity-actions"><span class="pill {{ 'public' if public_view else activity.visibility }}">{{ 'Shared via link' if public_view else activity.visibility|title }}</span>{% if owner_view %}<button type="button" class="button secondary copy-link-button" id="copy-run-link" aria-label="Copy public link to this run" title="Copy public link — anyone with the link can view this run" data-url="{{ url_for('web.share_activity', activity_id=activity.id) }}" data-csrf="{{ csrf_token() }}"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m10 13 4-4m-6 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m2 2 1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0"/></svg></button>{% endif %}</div></div>
{% if owner_view %}<div class="share-feedback"><p id="share-status" role="status" aria-live="polite"></p><label id="share-fallback" hidden>Copy this public link<input id="share-link-value" readonly aria-label="Public run link"></label></div>{% endif %}
{% if efforts %}
<section class="panel records-panel"><div class="section-head"><h2>Best efforts on this run</h2><span class="subtle-label">Estimated · elapsed time</span></div><div class="records-grid">{% for effort in efforts %}<article class="record-card"><span>{{ effort.label }} {% if effort.pr %}<span class="pr-badge">★ PR</span>{% endif %}</span><strong>{{ effort.seconds|duration }}</strong><small>{{ '%.2f'|format(effort.start_m/1000) }}–{{ '%.2f'|format(effort.end_m/1000) }} km into the run</small></article>{% endfor %}</div><p class="field-help">Based on recorded distance and timestamps, with interpolated boundaries. Stops count toward elapsed time. Missing or reset data breaks an effort. PR means a record set at the time of this run, including your first recorded effort.</p></section>
{% endif %}
<nav class="activity-nav" aria-label="Activity sections"><a href="#route-section">Route & speed</a><a href="#performance">Performance</a><a href="#terrain">Terrain & effort</a><a href="#splits">Laps & splits</a><a href="#method">About the numbers</a></nav>
<div class="stats-grid">
<article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div><p>Recorded by your watch</p></article>
+6 -1
View File
@@ -6,7 +6,7 @@
<div class="page-heading"><div><span class="eyebrow">{{ 'TRAINING LOG' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Overview' if personal else profile.display_name }}</h1><p>{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import activity</a>{% else %}<span class="pill public">Friends only</span>{% endif %}</div>
<section class="dashboard-route panel" aria-label="Latest running route">
<div class="dashboard-route-info"><span class="eyebrow">LATEST RUN / ROUTE REPLAY</span><h2>{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}</h2>
{% if mapped_run %}<p>{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km</p><a class="back-link" href="{{ url_for('web.activity',activity_id=mapped_run.id) }}">View run {{ icon('arrow') }}</a><button class="button secondary" id="toggle-route-replay" type="button" hidden>Pause replay</button>{% else %}<p>Import a run with GPS to see your route in motion.</p>{% endif %}
{% if mapped_run %}<p>{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km</p><a class="back-link" href="{{ url_for('web.activity',activity_id=mapped_run.id) }}">View run {{ icon('arrow') }}</a>{% else %}<p>Import a run with GPS to see your route in motion.</p>{% endif %}
<p class="field-help" id="dashboard-route-status" role="status">{{ 'Loading recorded route…' if mapped_run else 'No GPS running route available yet.' }}</p></div>
<div id="dashboard-route-map" {% if mapped_run %}data-url="{{ url_for('web.data',activity_id=mapped_run.id) }}"{% endif %} aria-label="Map of the latest recorded running route"></div>
</section>
@@ -20,6 +20,11 @@
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">WEEKLY DISTANCE</span><h2>Training volume</h2></div><span class="subtle-label">8 weeks to latest activity</span></div><div class="chart-wrap"><canvas id="volume-chart" aria-label="Weekly distance in kilometres" role="img"></canvas></div><script id="volume-data" type="application/json">{{ {'labels': stats.weeks, 'values': stats.weekly_km}|tojson }}</script></section>
<section class="milestone-card"><span class="eyebrow">TOTAL ASCENT</span><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation gain</h3><div class="elevation-profile" aria-hidden="true"><svg viewBox="0 0 300 70"><path d="m0 65 30-15 15 8 35-35 20 15 35-32 25 32 20-12 25 28 20-12 30 23 25-10"/></svg></div><p>Across {{ stats.count }} recorded activities</p></section>
</div>
{% if personal %}
<section class="panel records-panel"><div class="section-head"><div><span class="eyebrow">YOUR FASTEST DISTANCES</span><h2>Personal records</h2></div><span class="subtle-label">Estimated · elapsed time</span></div>
<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>
{% 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 %}
+29
View File
@@ -0,0 +1,29 @@
# Running personal records
The dashboard and owner's run page now calculate estimated best efforts for
1 km, 2 km, 5 km, 10 km, half marathon (21,097.5 m), and marathon (42,195 m).
These are fastest stretches anywhere within a run, rather than lap times or
times projected from average pace. All existing running activities participate.
Following [Strava's elapsed-time convention](https://support.strava.com/en-us/articles/15401661-how-do-best-efforts-work-for-running-on-strava),
stops inside an effort count. The calculation uses full-resolution FIT cumulative
distance and timestamps, with linear interpolation at distance boundaries. Missing
distance/time, backwards distance, and non-increasing timestamps split the data.
Timestamp gaps count as elapsed time; there is no subtraction for auto-pause.
No distance or time is inferred from an activity summary when samples are absent.
The dashboard lists current records. Runs earn historical PR badges if they
established or strictly improved a record on their activity date; equal times
do not earn another badge. Import order does not affect the result. Records and
badges are recalculated from history, so older imports and removals are reflected.
Comparisons currently appear only in the owner's dashboard and activity page.
These are estimates from watch data, not verified race records or an exact clone
of Strava's proprietary calculation. GPS/distance spikes or sparse recording can
affect results. There is currently no manual effort exclusion or correction UI.
The initial implementation computes on page requests without a database migration.
For large histories, the next step is to persist per-run efforts at import time
with an algorithm version, backfill existing runs, and query those small results.
Further additions could include second/third-best medals, annual records, manual
exclusion of bad recordings, and a selected-effort highlight on the route map.
+21 -1
View File
@@ -86,12 +86,32 @@ def test_owner_dashboard_activity_json_and_settings_render(app):
assert "point" not in data["chart"][0]
def test_personal_records_render_and_remain_owner_only(app):
with app.app_context():
row = db.session.get(Activity, 1)
row.samples = [{"distance": 0, "time": 0}, {"distance": 5000, "time": 1500}]
row.share_token = "record-test"
db.session.commit()
client = app.test_client()
login(client)
dashboard = client.get("/dashboard").data
assert b"Personal records" in dashboard
assert b"5 km PR" in dashboard
detail = client.get("/activities/1").data
assert b"Best efforts on this run" in detail
assert b"5 km" in detail
assert b"Best efforts on this run" not in client.get("/shared/runs/record-test").data
client.post("/logout")
login(client, "other")
assert b"5 km PR" not in client.get("/dashboard").data
def test_dashboard_replays_latest_accessible_gps_run(app):
client = app.test_client()
login(client)
page = client.get("/dashboard").data
assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page
assert b'id="toggle-route-replay"' in page
assert b'id="toggle-route-replay"' not in page
assert b'vendor/leaflet.js' in page
assert b'class="dashboard-map"' not in page
client.post("/logout")
+64
View File
@@ -0,0 +1,64 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from app.records import best_efforts, record_book
def run(points, id=1, sport="running"):
return SimpleNamespace(
id=id,
sport=sport,
started_at=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(days=id),
samples=[{"distance": d, "time": t} for d, t in points],
)
def test_fastest_window_can_start_between_samples():
efforts = best_efforts(run([(0, 0), (600, 300), (1500, 480), (2000, 730)]))
assert efforts[0]["seconds"] == pytest.approx(230)
assert efforts[0]["start_m"] == 500
assert efforts[1]["seconds"] == 730
def test_stops_inside_count_but_boundary_stops_do_not():
efforts = best_efforts(run([(0, 0), (0, 60), (500, 210), (500, 270), (1000, 420), (1000, 480)]))
assert efforts[0]["seconds"] == 360
@pytest.mark.parametrize(
"middle",
[
[(None, 150)],
[(float("nan"), 150)],
[(400, 200), (100, 210)],
[(500, 150), (600, 140)],
],
)
def test_invalid_or_reset_data_cannot_be_bridged(middle):
assert best_efforts(run([(0, 0), *middle, (1000, 330)])) == []
def test_missing_time_short_run_and_other_sports_have_no_records():
assert best_efforts(run([(0, None), (5000, None)])) == []
assert best_efforts(run([(0, 0), (999.99, 300)])) == []
assert best_efforts(run([(0, 0), (5000, 300)], sport="cycling")) == []
def test_record_history_backfills_ties_and_improvements():
first = run([(0, 0), (1000, 300)], id=1)
faster = run([(0, 0), (1000, 290)], id=2)
tie = run([(0, 0), (1000, 290)], id=3)
slower = run([(0, 0), (1000, 320)], id=4)
book = record_book([slower, tie, faster, first])
assert book["records"][0]["activity"] is faster
assert [book["runs"][i][0]["pr"] for i in range(1, 5)] == [True, True, False, False]
assert record_book([first, slower])["records"][0]["activity"] is first
def test_all_distances_and_timestamp_gaps_use_elapsed_time():
efforts = best_efforts(run([(0, 100), (42195, 12758.5)]))
assert len(efforts) == 6
assert efforts[0]["seconds"] == pytest.approx(300)
assert efforts[-1]["seconds"] == 12658.5