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 %}
| ACTIVITY | DISTANCE | TIME | AVG. PACE | {{ 'VISIBILITY' if personal|default(false) else 'ATHLETE' }} | Open |
|---|---|---|---|---|---|
| {{ 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') }} |
| {{ 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') }} |
{{ '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 %}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
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.
Recorded by your watch
{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}
{{ 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.' }}
Across {{ stats.count }} recorded activities
Fastest stretches within your runs, including stops. PR badges mark records set at the time of the run; your first effort establishes a record.
+Import a run with recorded distance and timestamps to establish your first record.
{% endfor %}