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 .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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
Reference in New Issue
Block a user