Add running personal records and historical PR badges
This commit is contained in:
@@ -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
@@ -27,6 +27,7 @@ from garmin_sync.service import SyncError, atomic_write
|
|||||||
from .analytics import analyse, pace_text
|
from .analytics import analyse, pace_text
|
||||||
from .extensions import db, limiter
|
from .extensions import db, limiter
|
||||||
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
|
from .services import activity_data, import_cache, import_upload
|
||||||
|
|
||||||
web = Blueprint("web", __name__)
|
web = Blueprint("web", __name__)
|
||||||
@@ -219,6 +220,7 @@ def dashboard():
|
|||||||
stats=overview(rows, current_user.timezone),
|
stats=overview(rows, current_user.timezone),
|
||||||
profile=current_user,
|
profile=current_user,
|
||||||
personal=True,
|
personal=True,
|
||||||
|
record_book=record_book(rows),
|
||||||
mapped_run=latest_mapped_run(rows),
|
mapped_run=latest_mapped_run(rows),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,8 +312,17 @@ def profile(username):
|
|||||||
def activity(activity_id):
|
def activity(activity_id):
|
||||||
row, owner = get_activity(activity_id)
|
row, owner = get_activity(activity_id)
|
||||||
analysis = analyse(row)
|
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(
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ async function showDashboardRoute() {
|
|||||||
const container = document.getElementById("dashboard-route-map");
|
const container = document.getElementById("dashboard-route-map");
|
||||||
if (!container?.dataset.url) return;
|
if (!container?.dataset.url) return;
|
||||||
const status = document.getElementById("dashboard-route-status");
|
const status = document.getElementById("dashboard-route-status");
|
||||||
const toggle = document.getElementById("toggle-route-replay");
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(container.dataset.url);
|
const response = await fetch(container.dataset.url);
|
||||||
if (!response.ok) throw new Error("Route unavailable");
|
if (!response.ok) throw new Error("Route unavailable");
|
||||||
@@ -57,12 +56,9 @@ async function showDashboardRoute() {
|
|||||||
const update = () => {
|
const update = () => {
|
||||||
if (frame !== null) cancelAnimationFrame(frame);
|
if (frame !== null) cancelAnimationFrame(frame);
|
||||||
frame = null; previous = null;
|
frame = null; previous = null;
|
||||||
toggle.textContent = paused ? "Play replay" : "Pause replay";
|
status.hidden = true;
|
||||||
status.textContent = paused ? "Replay paused. Explore the recorded route." : "Following the recorded route · 20-second replay";
|
|
||||||
if (!paused && !document.hidden) frame = requestAnimationFrame(tick);
|
if (!paused && !document.hidden) frame = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
toggle.hidden = false;
|
|
||||||
toggle.onclick = () => { paused = !paused; update(); };
|
|
||||||
preference.addEventListener("change", event => { paused = event.matches; update(); });
|
preference.addEventListener("change", event => { paused = event.matches; update(); });
|
||||||
document.addEventListener("visibilitychange", update);
|
document.addEventListener("visibilitychange", update);
|
||||||
update();
|
update();
|
||||||
|
|||||||
@@ -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}}
|
.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. */
|
/* Keep account controls at the bottom without a decorative sidebar map. */
|
||||||
.sidebar .sidebar-bottom{margin-top:auto}
|
.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,5 +1,5 @@
|
|||||||
{% if rows %}
|
{% 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>
|
<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>
|
</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 %}
|
||||||
|
|||||||
@@ -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 %}
|
{% 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>
|
<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 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>
|
<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">
|
<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>
|
<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,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>
|
<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">
|
<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>
|
<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>
|
<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>
|
<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>
|
</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="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>
|
<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>
|
</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>
|
<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 %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -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
@@ -86,12 +86,32 @@ def test_owner_dashboard_activity_json_and_settings_render(app):
|
|||||||
assert "point" not in data["chart"][0]
|
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):
|
def test_dashboard_replays_latest_accessible_gps_run(app):
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
login(client)
|
login(client)
|
||||||
page = client.get("/dashboard").data
|
page = client.get("/dashboard").data
|
||||||
assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page
|
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'vendor/leaflet.js' in page
|
||||||
assert b'class="dashboard-map"' not in page
|
assert b'class="dashboard-map"' not in page
|
||||||
client.post("/logout")
|
client.post("/logout")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user