53 lines
13 KiB
HTML
53 lines
13 KiB
HTML
{% extends 'base.html' %}
|
||
{% block title %}{{ activity.title }} · Stride{% endblock %}
|
||
{% block breadcrumb %}ACTIVITY / {{ activity.sport|upper }}{% endblock %}
|
||
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
|
||
{% macro metric(label, value, unit='', note='') %}
|
||
<article class="metric-tile"><span>{{ label }}</span><strong>{{ value }}<small>{{ unit }}</small></strong>{% if note %}<p>{{ note }}</p>{% endif %}</article>
|
||
{% endmacro %}
|
||
{% block content %}
|
||
{% 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>
|
||
<article class="stat-card"><div class="stat-label">TIMER TIME</div><div class="stat-value time-value">{{ activity.duration_s|duration }}</div><p>Elapsed {{ activity.metrics.get('total_elapsed_time')|duration if activity.metrics.get('total_elapsed_time') else '—' }}</p></article>
|
||
<article class="stat-card"><div class="stat-label">AVERAGE PACE</div><div class="stat-value">{{ activity.pace }}<small>/km</small></div><p>GAP estimate <strong>{{ pace_text(analysis.summary.gap) }}/km</strong></p></article>
|
||
<article class="stat-card"><div class="stat-label">AVERAGE HEART RATE</div><div class="stat-value">{{ activity.avg_hr|int if activity.avg_hr else '—' }}<small>bpm</small></div><p>Peak {{ analysis.summary.max_hr|int if analysis.summary.max_hr else '—' }} bpm</p></article>
|
||
</div>
|
||
<section class="panel map-panel run-map" id="route-section">
|
||
<div class="section-head"><div><span class="eyebrow">FOLLOW YOUR FOOTSTEPS</span><h2>Your run, in colour.</h2></div><div class="map-controls"><label>Colour by <select id="map-metric"><option value="speed">Speed</option><option value="heart_rate">Heart rate</option><option value="grade">Gradient</option></select></label><label class="directions-toggle"><input type="checkbox" id="show-directions" checked> Direction arrows</label><button type="button" class="button secondary" id="fit-route">Fit route</button></div></div>
|
||
<div id="route-map" data-url="{{ data_url or url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></div>
|
||
<div class="route-legend"><span id="legend-low">Slower</span><div class="legend-ramp" id="legend-ramp"></div><span id="legend-high">Faster</span><span class="legend-note">S Start · F Finish · Grey = no measurement</span></div>
|
||
<p id="map-status" class="map-status" aria-live="polite"></p>
|
||
</section>
|
||
<section id="performance" class="performance-section">
|
||
<div class="section-title"><span class="eyebrow">A CLOSER LOOK</span><h2>The numbers behind the run.</h2><p>Hover over a chart to locate that part of the route.</p></div>
|
||
<div class="metrics-strip">
|
||
{{ metric('GRADE-ADJUSTED PACE', pace_text(analysis.summary.gap), '/km', 'Model estimate · %.0f%% distance coverage'|format(analysis.summary.gap_coverage)) }}
|
||
{{ metric('ELEVATION GAIN', activity.elevation_m|int, 'm', 'Descent %s m'|format(activity.metrics.get('total_descent')|int if activity.metrics.get('total_descent') is not none else '—')) }}
|
||
{{ metric('AVERAGE CADENCE', '%.0f'|format(analysis.summary.cadence) if analysis.summary.cadence else '—', 'spm', 'Both feet · sampled average') }}
|
||
{{ metric('AVERAGE POWER', activity.metrics.get('avg_power')|int if activity.metrics.get('avg_power') is not none else '—', 'W', 'Recorded running power') }}
|
||
{{ metric('ENERGY', activity.metrics.get('total_calories')|int if activity.metrics.get('total_calories') is not none else '—', 'kcal', 'Watch estimate') }}
|
||
{{ metric('TRAINING EFFECT', activity.metrics.get('total_training_effect') if activity.metrics.get('total_training_effect') is not none else '—', '', 'Aerobic · watch estimate') }}
|
||
</div>
|
||
<div class="detail-charts analytics-charts">
|
||
<section class="panel"><div class="section-head"><h2>Pace & grade-adjusted pace</h2><span class="subtle-label">min/km · lower is faster</span></div><div class="chart-wrap"><canvas id="pace-chart" role="img" aria-label="Pace and estimated grade adjusted pace over distance"></canvas></div></section>
|
||
<section class="panel"><div class="section-head"><h2>Heart rate</h2><span class="subtle-label">Recorded · bpm</span></div><div class="chart-wrap"><canvas id="hr-chart" role="img" aria-label="Recorded heart rate over distance"></canvas></div></section>
|
||
<section class="panel"><div class="section-head"><h2>Elevation & gradient</h2><span class="subtle-label">metres / % grade</span></div><div class="chart-wrap"><canvas id="elevation-chart" role="img" aria-label="Elevation and smoothed gradient"></canvas></div></section>
|
||
<section class="panel"><div class="section-head"><h2>Cadence & step length</h2><span class="subtle-label">steps/min / metres</span></div><div class="chart-wrap"><canvas id="cadence-chart" role="img" aria-label="Cadence and step length"></canvas></div></section>
|
||
<section class="panel"><div class="section-head"><h2>Running power</h2><span class="subtle-label">Recorded · watts</span></div><div class="chart-wrap"><canvas id="power-chart" role="img" aria-label="Running power"></canvas></div></section>
|
||
<section class="panel"><div class="section-head"><h2>Running dynamics</h2><span class="subtle-label">vertical movement / contact time</span></div><div class="chart-wrap"><canvas id="dynamics-chart" role="img" aria-label="Vertical oscillation and ground contact time"></canvas></div></section>
|
||
</div>
|
||
</section>
|
||
<section class="panel terrain-panel" id="terrain"><div class="section-head"><div><span class="eyebrow">HILLS CHANGE THE EFFORT</span><h2>Heart rate by terrain</h2></div><span class="subtle-label">Measured HR · not adjusted</span></div><p class="section-description">See how your recorded heart rate and pace vary uphill, downhill and on the flat. Heart rate responds with a delay, so this is context rather than a correction.</p><div class="terrain-grid">{% for t in analysis.terrain %}<article><span>{{ t.label }}</span><strong>{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}<small>bpm</small></strong><div>{{ '%.2f'|format(t.distance/1000) }} km <span>·</span> {{ pace_text(t.pace) }}/km</div></article>{% endfor %}</div></section>
|
||
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
|
||
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity in Stride?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Public link access is separate: anyone with a copied link can view this run, including its full route and measurements, until you disable the link.</p><button class="button primary">Save changes {{ icon('arrow') }}</button></form><form id="revoke-share" method="post" action="{{ url_for('web.revoke_activity_share',activity_id=activity.id) }}" {{ 'hidden' if not activity.share_token }}><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disable public link</button></form></section>{% endif %}</div>
|
||
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.</p><p>Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.</p><p><strong>Heart rate stays measured.</strong> A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.</p><p>Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Chart traces are sampled for display; computations use all available records.</p></div></details>
|
||
{% 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='activity.js') }}"></script>{% endblock %}
|