Files
Stride/app/templates/activity.html
T

53 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}
<div class="record-badges run-record-badges" aria-label="Best efforts on this run">{% for effort in efforts %}<span class="record-badge {{ 'is-pr' if effort.pr }}" title="{{ '%.2f'|format(effort.start_m/1000) }}–{{ '%.2f'|format(effort.end_m/1000) }} km into the run"><span>{{ effort.label }}</span><strong>{{ pace_text(effort.seconds * 1000 / effort.distance) }}<small>/km</small></strong>{% if effort.pr %}<span class="badge-award">★ PR</span>{% endif %}</span>{% endfor %}</div>
{% 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">{{ analysis.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. Routes are simplified for display, preserving segment endpoints and GPS gaps. Map measurements and 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 %}