From a82135bc8988b36023300e054b7c213ca2d1f7f0 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Fri, 2 Oct 2026 13:52:27 +1000 Subject: [PATCH] Add running personal records and historical PR badges --- app/records.py | 93 ++++++++++++++++++++++++++++++++++ app/routes.py | 13 ++++- app/static/dashboard.js | 6 +-- app/static/style.css | 10 ++++ app/templates/_activities.html | 2 +- app/templates/activity.html | 3 ++ app/templates/dashboard.html | 7 ++- docs/PERSONAL_RECORDS.md | 29 +++++++++++ tests/test_app.py | 22 +++++++- tests/test_records.py | 64 +++++++++++++++++++++++ 10 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 app/records.py create mode 100644 docs/PERSONAL_RECORDS.md create mode 100644 tests/test_records.py diff --git a/app/records.py b/app/records.py new file mode 100644 index 0000000..b2b548d --- /dev/null +++ b/app/records.py @@ -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} diff --git a/app/routes.py b/app/routes.py index 89c1699..508727c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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, ) diff --git a/app/static/dashboard.js b/app/static/dashboard.js index 90ee64e..da4b624 100644 --- a/app/static/dashboard.js +++ b/app/static/dashboard.js @@ -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(); diff --git a/app/static/style.css b/app/static/style.css index 8856b3e..0453fb9 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; } diff --git a/app/templates/_activities.html b/app/templates/_activities.html index e39db33..ad6fa92 100644 --- a/app/templates/_activities.html +++ b/app/templates/_activities.html @@ -1,5 +1,5 @@ {% if rows %}
-{% for row in rows %}{% endfor %} +{% for row in rows %}{% endfor %}
ACTIVITYDISTANCETIMEAVG. PACE{{ 'VISIBILITY' if personal|default(false) else 'ATHLETE' }}Open
{{ icon('run') }}{{ row.title }}{{ row.started_at|localdate(row.owner.timezone) }} · {{ row.sport|replace('_',' ')|title }}{{ '%.2f'|format(row.distance_km) }} km{{ row.duration_s|duration }}{{ row.pace }} /km{% if personal|default(false) %}{{ row.visibility|title }}{% else %}{{ row.owner.display_name }}{% endif %}{{ icon('arrow') }}
{{ icon('run') }}{{ row.title }}{% if record_book is defined %}{% for effort in record_book.runs.get(row.id, []) if effort.pr %}★ {{ effort.label }} PR{% endfor %}{% endif %}{{ row.started_at|localdate(row.owner.timezone) }} · {{ row.sport|replace('_',' ')|title }}{{ '%.2f'|format(row.distance_km) }} km{{ row.duration_s|duration }}{{ row.pace }} /km{% if personal|default(false) %}{{ row.visibility|title }}{% else %}{{ row.owner.display_name }}{% endif %}{{ icon('arrow') }}
{% else %}
↗

No activities yet.

{{ '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.' }}

{% if personal|default(false) %}Import your first run {{ icon('arrow') }}{% endif %}
{% endif %} diff --git a/app/templates/activity.html b/app/templates/activity.html index 1f2b8f3..29c8a52 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -9,6 +9,9 @@ {% if not public_view %}← {{ 'Your activities' if owner_view else activity.owner.display_name }}{% endif %}
{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}

{{ activity.title }}

By {% if public_view %}{{ activity.owner.display_name }}{% else %}{{ activity.owner.display_name }}{% endif %}·{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}·{{ '{:,}'.format(analysis.summary.samples) }} recorded samples

{{ 'Shared via link' if public_view else activity.visibility|title }}{% if owner_view %}{% endif %}
{% if owner_view %}

{% endif %} +{% if efforts %} +

Best efforts on this run

Estimated · elapsed time
{% for effort in efforts %}
{{ effort.label }} {% if effort.pr %}★ PR{% endif %}{{ effort.seconds|duration }}{{ '%.2f'|format(effort.start_m/1000) }}–{{ '%.2f'|format(effort.end_m/1000) }} km into the run
{% endfor %}

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.

+{% endif %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 76e81af..ecdc251 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -6,7 +6,7 @@
{{ 'TRAINING LOG' if personal else 'SHARED WITH FRIENDS' }}

{{ 'Overview' if personal else profile.display_name }}

{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}

{% if personal %}{{ icon('plus') }} Import activity{% else %}Friends only{% endif %}
LATEST RUN / ROUTE REPLAY

{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}

- {% if mapped_run %}

{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km

View run {{ icon('arrow') }}{% else %}

Import a run with GPS to see your route in motion.

{% endif %} + {% if mapped_run %}

{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km

View run {{ icon('arrow') }}{% else %}

Import a run with GPS to see your route in motion.

{% endif %}

{{ 'Loading recorded route…' if mapped_run else 'No GPS running route available yet.' }}

@@ -20,6 +20,11 @@
WEEKLY DISTANCE

Training volume

8 weeks to latest activity
TOTAL ASCENT
{{ '{:,.0f}'.format(stats.elevation) }}m

Elevation gain

Across {{ stats.count }} recorded activities

+{% if personal %} +
YOUR FASTEST DISTANCES

Personal records

Estimated · elapsed time
+

Fastest stretches within your runs, including stops. PR badges mark records set at the time of the run; your first effort establishes a record.

+
{% for record in record_book.records %}{{ record.label }}{{ record.seconds|duration }}{{ record.activity.started_at|localdate(profile.timezone) }} · {{ record.activity.title }}{% else %}

Import a run with recorded distance and timestamps to establish your first record.

{% endfor %}
+{% endif %}
SESSION HISTORY

{{ 'Your activities' if personal else 'Shared activities' }}

{{ rows|length }} activities
{% include '_activities.html' %}
{% endblock %} {% block scripts %}{% endblock %} diff --git a/docs/PERSONAL_RECORDS.md b/docs/PERSONAL_RECORDS.md new file mode 100644 index 0000000..87dd094 --- /dev/null +++ b/docs/PERSONAL_RECORDS.md @@ -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. diff --git a/tests/test_app.py b/tests/test_app.py index 78269e3..073f9a9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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") diff --git a/tests/test_records.py b/tests/test_records.py new file mode 100644 index 0000000..0d2dc09 --- /dev/null +++ b/tests/test_records.py @@ -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