Deploy Stride with encrypted manual and scheduled Garmin sync

This commit is contained in:
Peter Stockings
2026-10-01 20:42:28 +10:00
parent 24c7fc2c6a
commit 8555695c5e
28 changed files with 900 additions and 56 deletions
+2 -2
View File
@@ -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">↗</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) }}">↗</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><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>Your next chapter awaits.</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 ↗</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 %}
+22
View File
@@ -0,0 +1,22 @@
{% macro icon(name) -%}
<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">{% if name == 'overview' %}<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>{% elif name == 'profile' %}<circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>{% elif name == 'friends' %}<circle cx="9" cy="8" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3M16 5a3 3 0 0 1 0 6m2 4a5 5 0 0 1 3 5"/>{% elif name == 'settings' %}<path d="M4 7h16M4 17h16"/><circle cx="8" cy="7" r="3"/><circle cx="16" cy="17" r="3"/>{% elif name == 'clock' %}<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>{% elif name == 'route' %}<circle cx="5" cy="18" r="2"/><circle cx="19" cy="6" r="2"/><path d="M5 16V7a3 3 0 0 1 6 0v10a3 3 0 0 0 6 0V6"/>{% elif name == 'run' %}<circle cx="15" cy="4" r="2"/><path d="m5 9 5-2 4 4 5 1M12 9l-3 6 5 3v4M9 15l-3 5H2"/>{% elif name == 'plus' %}<path d="M12 5v14M5 12h14"/>{% elif name == 'upload' %}<path d="M12 16V3m-5 5 5-5 5 5M4 15v6h16v-6"/>{% else %}<path d="M4 12h16m-6-6 6 6-6 6"/>{% endif %}</svg>
{%- endmacro %}
{% macro running_map(compact=false) -%}
<div class="running-map {{ 'compact-map' if compact }}">
<svg viewBox="0 0 760 320" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Illustrated running route through a city park">
<rect width="760" height="320" fill="#171d20"/>
<g fill="#202d29" stroke="#2b3b33" stroke-width="1"><path d="m260 45 170-20 62 88-36 93-173-11-52-63Z"/><path d="m545 202 140-22 57 120H548Z"/></g>
<path d="M-20 270C110 190 112 342 278 268S480 259 560 123 711 81 800 8" fill="none" stroke="#253943" stroke-width="35"/>
<g fill="none" stroke="#30383b" stroke-width="9"><path d="M-20 52 790 52M-20 155 200 155 239 215 790 215M45-20V340M166-20V340M539-20V340M680-20V340M-20 305 790 305M290-20 290 30M430-20 430 24M332 210V340"/></g>
<g fill="none" stroke="#41484a" stroke-width="1"><path d="M-20 52 790 52M-20 155 200 155 239 215 790 215M45-20V340M166-20V340M539-20V340M680-20V340M-20 305 790 305"/></g>
<g fill="none" stroke="#2b3336" stroke-width="3"><path d="M95 0v320M0 103h215M0 255h165M590 0v320M730 0v320M495 105h265M540 265h220M215 0v100M377 228v92"/></g>
<g font-family="monospace" font-size="9" letter-spacing="2" fill="#788682"><text x="313" y="114">NORTH PARK</text><text x="567" y="44">PARK AVENUE</text><text x="72" y="205" transform="rotate(-90 72 205)">RIVER STREET</text><text x="565" y="290">RIVERSIDE</text></g>
<path class="map-route-glow" d="M166 215V52H430L475 112 449 175H290L250 128H166V215"/>
<path class="map-route" d="M166 215V52H430L475 112 449 175H290L250 128H166V215"/>
<circle cx="166" cy="215" r="6" fill="#171d20" stroke="#ff8659" stroke-width="3"/>
<g class="map-runner"><circle r="15" fill="#ff8659" opacity=".16"/><circle r="6" fill="#fff0e6" stroke="#ff8659" stroke-width="3"/></g>
<g transform="translate(713 28)" fill="none" stroke="#94a19e"><path d="M0 24V0m-5 8 5-8 5 8"/><text x="-3" y="-8" fill="#94a19e" stroke="none" font-size="9">N</text></g>
</svg>
<div class="map-caption"><span><i></i> RUN THE CITY <small>Illustrated route</small></span></div>
</div>
{%- endmacro %}
+5 -4
View File
@@ -6,8 +6,9 @@
<article class="metric-tile"><span>{{ label }}</span><strong>{{ value }}<small>{{ unit }}</small></strong>{% if note %}<p>{{ note }}</p>{% endif %}</article>
{% endmacro %}
{% block content %}
<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>
<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 <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a><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><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
{% 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 %}
<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>
@@ -17,7 +18,7 @@
</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="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></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>
@@ -42,7 +43,7 @@
</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?<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. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
{% 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 %}
+2 -2
View File
@@ -3,7 +3,7 @@
{% block breadcrumb %}{{ 'WELCOME' if mode == 'setup' else 'ACCOUNT' }}{% endblock %}
{% block content %}
<div class="auth-layout">
<section class="auth-intro"><span class="eyebrow">THE NEXT CHAPTER STARTS HERE</span><h1>Your miles.<br>Your story.<br><em>Your space.</em></h1><p>A home for the runs, the quiet progress,<br>and everything you’re working towards.</p><div class="auth-art" aria-hidden="true"><svg viewBox="0 0 460 230"><path d="M-20 185 C90 50 100 275 235 90 S365 170 490 20"/><path d="M-20 209 C90 74 100 299 235 114 S365 194 490 44"/><circle cx="235" cy="90" r="8"/></svg></div></section>
<section class="auth-intro"><span class="eyebrow">MADE FOR THE LONG RUN</span><h1>Run. Record.<br><em>Repeat.</em></h1><p>Every route. Every split. Your progress.<br>A home for all the miles you put in.</p><ul class="landing-features"><li>{{ icon('route') }} Replay your routes</li><li>{{ icon('overview') }} See your progress</li><li>{{ icon('friends') }} Share your runs</li></ul><div class="auth-art landing-map">{{ running_map() }}</div></section>
<section class="panel auth-card">
<span class="eyebrow">{{ 'FIRST STEPS' if mode == 'setup' else 'WELCOME BACK' if mode == 'login' else 'FIND YOUR STRIDE' }}</span>
<h2>{{ 'Make yourself at home.' if mode == 'setup' else 'Good to see you.' if mode == 'login' else 'Create your account.' }}</h2>
@@ -17,7 +17,7 @@
{% endif %}
<label>Email address<input type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com" value="{{ request.form.get('email','') }}"></label>
<label>Password<input type="password" name="password" required minlength="{{ 1 if mode == 'login' else 12 }}" maxlength="128" autocomplete="{{ 'current-password' if mode == 'login' else 'new-password' }}" placeholder="{{ 'Your password' if mode == 'login' else 'At least 12 characters' }}"></label>
<button class="button primary full">{{ 'Sign in' if mode == 'login' else 'Create account' }} <span>↗</span></button>
<button class="button primary full">{{ 'Sign in' if mode == 'login' else 'Create account' }} {{ icon('arrow') }}</button>
</form>
<p class="auth-foot">{% if mode == 'login' %}New here? <a href="{{ url_for('web.register') }}">Create an account</a>{% else %}Already have an account? <a href="{{ url_for('web.login') }}">Sign in</a>{% endif %}</p>
{% if mode != 'login' %}<small class="muted">This is your Stride password, separate from your Garmin account.</small>{% endif %}
+24 -12
View File
@@ -1,3 +1,4 @@
{% from '_ui.html' import icon, running_map %}
<!doctype html>
<html lang="en">
<head>
@@ -7,36 +8,47 @@
<link rel="icon" href="{{ url_for('static', filename='mark.svg') }}" type="image/svg+xml">
{% block head %}{% endblock %}
</head>
<body>
<body class="{{ 'app-shell' if current_user.is_authenticated else 'public-shell' }}">
{% if current_user.is_authenticated %}
<aside class="sidebar">
<a class="brand" href="{{ url_for('web.index') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<div class="sidebar-label">YOUR RUNNING STORY</div>
<div class="sidebar-label">TRAINING LOG</div>
<nav aria-label="Main navigation">
{% if current_user.is_authenticated %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}"><span>◫</span> Overview</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}"><span>◎</span> Shared profile</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">{{ icon('overview') }} Overview</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}">{{ icon('profile') }} Shared profile</a>
{% endif %}
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}"><span>↗</span> Friends</a>{% endif %}
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}">{{ icon('friends') }} Friends</a>{% endif %}
{% if current_user.is_authenticated %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}"><span>⚙</span> Settings & imports</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}">{{ icon('settings') }} Settings & imports</a>
{% else %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}"><span>↪</span> Sign in</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}">{{ icon('arrow') }} Sign in</a>
{% endif %}
</nav>
<div class="sidebar-note"><span class="tiny-label">A LITTLE FURTHER.</span><p>Every run adds<br>to your story.</p><div class="trail-art" aria-hidden="true"><svg viewBox="0 0 180 95"><path d="M-10 80 C30 30 70 110 90 55 S120 5 195 10"/><path d="M-10 94 C30 44 70 124 90 69 S120 19 195 24"/></svg></div></div>
<div class="sidebar-bottom">
{% if current_user.is_authenticated %}
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Your running space</small></div></div>
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out ↗</button></form>
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Runner</small></div></div>
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out {{ icon('arrow') }}</button></form>
{% else %}<span class="muted">Made for the long run.</span>{% endif %}
</div>
</aside>
{% else %}
<header class="landing-header">
<a class="brand" href="{{ url_for('web.index') }}" aria-label="Stride home"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<nav aria-label="Account navigation">
{% if request.endpoint == 'web.login' %}<a class="button secondary" href="{{ url_for('web.register') }}">Create account {{ icon('arrow') }}</a>
{% else %}<a class="button secondary" href="{{ url_for('web.login') }}">Sign in {{ icon('arrow') }}</a>{% endif %}
</nav>
</header>
{% endif %}
<main>
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Your data. Your pace.</span></header>
{% if current_user.is_authenticated %}
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Personal running log</span></header>
{% endif %}
<div class="page">
{% for category, message in get_flashed_messages(with_categories=true) %}<div role="status" class="flash {{ category }}">{{ message }}</div>{% endfor %}
{% block content %}{% endblock %}
<footer class="footer"><span>STRIDE · EVERY KILOMETRE COUNTS</span><span>Built for your own path.</span></footer>
<footer class="footer"><span>STRIDE / RUNNING LOG</span><span>DISTANCE · TIME · PROGRESS</span></footer>
</div>
</main>
{% block scripts %}{% endblock %}
+16 -9
View File
@@ -1,18 +1,25 @@
{% extends 'base.html' %}
{% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %}
{% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% block content %}
<div class="page-heading"><div><span class="eyebrow">{{ 'THE BIG PICTURE' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Find your stride.' if personal else profile.display_name }}</h1><p>{{ 'A little perspective on every kilometre you’ve put in.' if personal else 'The runs, the progress, and the miles along the way.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">+ 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">
<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 %}
<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>
<div class="stats-grid">
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED <span>↗</span></div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
<article class="stat-card"><div class="stat-label">ACTIVITIES <span>◫</span></div><div class="stat-value">{{ stats.count }}</div><p>One step, then another</p></article>
<article class="stat-card"><div class="stat-label">TIME ON YOUR FEET <span>◷</span></div><div class="stat-value time-value">{{ stats.duration|duration }}</div><p>Total recorded timer time</p></article>
<article class="stat-card"><div class="stat-label">AVERAGE PACE <span>↝</span></div><div class="stat-value">{{ stats.pace }}<small>/km</small></div><p>Weighted by distance</p></article>
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED {{ icon('route') }}</div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
<article class="stat-card"><div class="stat-label">ACTIVITIES {{ icon('run') }}</div><div class="stat-value">{{ stats.count }}</div><p>Recorded sessions</p></article>
<article class="stat-card"><div class="stat-label">TIME ON YOUR FEET {{ icon('clock') }}</div><div class="stat-value time-value">{{ stats.duration|duration }}</div><p>Total recorded timer time</p></article>
<article class="stat-card"><div class="stat-label">AVERAGE PACE {{ icon('route') }}</div><div class="stat-value">{{ stats.pace }}<small>/km</small></div><p>Weighted by distance</p></article>
</div>
<div class="overview-grid">
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">CONSISTENCY ADDS UP</span><h2>Your miles, over time.</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">THE VIEW FROM UP HERE</span><div class="mountain" aria-hidden="true"><svg viewBox="0 0 300 120"><path d="M-20 120 L85 36 L123 76 L177 9 L310 120"/><path d="M10 120 L90 65 L123 101 L177 38 L290 120"/><circle cx="239" cy="28" r="16"/></svg></div><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation earned.</h3><p>Every climb is part of the story.</p></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>
</div>
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">THE WORK YOU’VE PUT IN</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 %}
{% block scripts %}<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 %}
+1 -1
View File
@@ -1,2 +1,2 @@
{% extends 'base.html' %}
{% block content %}<div class="empty-state"><span class="eyebrow">{{ code }}</span><h1>{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}</h1><p>{{ 'This page is private or doesn’t exist.' if code == 404 else 'Too many attempts. Please wait a little.' if code == 429 else 'That upload is too large. The limit is 16 MB.' if code == 413 else 'The request could not be completed. Refresh the page and try again.' }}</p><a class="button primary" href="{{ url_for('web.index') }}">Back to Stride ↗</a></div>{% endblock %}
{% block content %}<div class="empty-state"><span class="eyebrow">{{ code }}</span><h1>{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}</h1><p>{{ 'This page is private or doesn’t exist.' if code == 404 else 'Too many attempts. Please wait a little.' if code == 429 else 'That upload is too large. The limit is 16 MB.' if code == 413 else 'The request could not be completed. Refresh the page and try again.' }}</p><a class="button primary" href="{{ url_for('web.index') }}">Back to Stride {{ icon('arrow') }}</a></div>{% endblock %}
+2 -2
View File
@@ -4,9 +4,9 @@
{% block content %}
<div class="page-heading"><div><span class="eyebrow">YOUR PEOPLE, YOUR PACE</span><h1>Better with friends.</h1><p>Only accepted friends can see the activities you share.</p></div></div>
<div class="settings-grid friends-top">
<section class="panel settings-card"><span class="eyebrow">INVITE SOMEONE YOU KNOW</span><h2>Add a friend</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Their Stride email<input type="email" name="email" required maxlength="254" placeholder="friend@example.com"></label><p class="field-help">They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.</p><button class="button primary">Send friend request ↗</button></form></section>
<section class="panel settings-card"><span class="eyebrow">INVITE SOMEONE YOU KNOW</span><h2>Add a friend</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Their Stride email<input type="email" name="email" required maxlength="254" placeholder="friend@example.com"></label><p class="field-help">They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.</p><button class="button primary">Send friend request {{ icon('arrow') }}</button></form></section>
<section class="panel settings-card"><span class="eyebrow">YOUR CIRCLE</span><h2>Connections</h2>
{% for entry in people %}<div class="friend-row"><div><strong>{% if entry.link.status == 'accepted' %}<a href="{{ url_for('web.profile', username=entry.person.username) }}">{{ entry.person.display_name }} ↗</a>{% else %}{{ entry.person.display_name }}{% endif %}</strong><small class="muted">{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}</small></div><div class="friend-actions">
{% for entry in people %}<div class="friend-row"><div><strong>{% if entry.link.status == 'accepted' %}<a href="{{ url_for('web.profile', username=entry.person.username) }}">{{ entry.person.display_name }} {{ icon('arrow') }}</a>{% else %}{{ entry.person.display_name }}{% endif %}</strong><small class="muted">{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}</small></div><div class="friend-actions">
{% if entry.link.status == 'pending' and entry.link.requested_by != current_user.id %}<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='accept') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Accept</button></form>{% endif %}
<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='remove') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">{{ 'Remove' if entry.link.status == 'accepted' else 'Cancel' if entry.link.requested_by == current_user.id else 'Decline' }}</button></form></div></div>{% else %}<p class="muted">Your circle starts here. Add a friend using the email they registered with.</p>{% endfor %}
</section></div>
+14 -4
View File
@@ -2,8 +2,18 @@
{% block title %}Settings & imports · Stride{% endblock %}
{% block breadcrumb %}SETTINGS & IMPORTS{% endblock %}
{% block content %}
<div class="page-heading"><div><span class="eyebrow">MAKE IT YOUR OWN</span><h1>Your space, your way.</h1><p>Manage your profile and bring your activities home.</p></div></div>
<div class="settings-grid"><section class="panel settings-card"><span class="eyebrow">THE PERSON BEHIND THE MILES</span><h2>Your profile</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Display name<input name="display_name" value="{{ current_user.display_name }}" required maxlength="80"></label><label>Time zone<input name="timezone" value="{{ current_user.timezone }}" list="timezones" required><datalist id="timezones"><option>Australia/Sydney</option><option>Australia/Melbourne</option><option>Australia/Brisbane</option><option>UTC</option><option>Europe/London</option><option>America/New_York</option></datalist></label><p class="field-help">Your <a href="{{ url_for('web.profile',username=current_user.username) }}">shared profile</a> is visible only to accepted friends and only includes activities you share.</p><button class="button primary">Save profile ↗</button></form></section>
<section class="panel settings-card"><span class="eyebrow">BRING YOUR RUNS WITH YOU</span><h2>Import a FIT file</h2><p class="muted">Upload an original activity exported from your watch or Garmin Connect.</p><form action="{{ url_for('web.upload') }}" method="post" enctype="multipart/form-data" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="upload-zone"><span class="upload-icon">↑</span><strong>Choose an activity file</strong><small>FIT format · up to 16 MB · imported privately</small><input type="file" name="file" accept=".fit" required></label><button class="button primary">Import activity ↗</button></form></section>
<section class="panel settings-card connection-card"><div><span class="eyebrow">GARMIN CONNECT</span><h2>Your local sync</h2><p class="muted">{% if current_user.local_account_key %}Your account is linked to the activities downloaded by your local Garmin sync command.{% else %}You can import FIT files now. Web-based Garmin token connections are coming in the next development stage.{% endif %}</p></div>{% if current_user.local_account_key %}<div class="connection-actions"><span class="pill public">Local imports linked</span><p class="field-help">After running the sync command, load any new downloads here. This button reads local files; it does not contact Garmin.</p><form method="post" action="{{ url_for('web.import_local') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Load new downloads ↻</button></form></div>{% endif %}</section></div>
<div class="page-heading"><div><span class="eyebrow">ACCOUNT</span><h1>Settings & imports</h1><p>Manage your profile and bring your activities home.</p></div></div>
<div class="settings-grid"><section class="panel settings-card"><span class="eyebrow">PROFILE</span><h2>Your profile</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Display name<input name="display_name" value="{{ current_user.display_name }}" required maxlength="80"></label><label>Time zone<input name="timezone" value="{{ current_user.timezone }}" list="timezones" required><datalist id="timezones"><option>Australia/Sydney</option><option>Australia/Melbourne</option><option>Australia/Brisbane</option><option>UTC</option><option>Europe/London</option><option>America/New_York</option></datalist></label><p class="field-help">Your <a href="{{ url_for('web.profile',username=current_user.username) }}">shared profile</a> is visible only to accepted friends and only includes activities you share.</p><button class="button primary">Save profile {{ icon('arrow') }}</button></form></section>
<section class="panel settings-card"><span class="eyebrow">ACTIVITY IMPORT</span><h2>Import a FIT file</h2><p class="muted">Upload an original activity exported from your watch or Garmin Connect.</p><form action="{{ url_for('web.upload') }}" method="post" enctype="multipart/form-data" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="upload-zone"><span class="upload-icon">{{ icon('upload') }}</span><strong>Choose an activity file</strong><small>FIT format · up to 16 MB · imported privately</small><input type="file" name="file" accept=".fit" required></label><button class="button primary">Import activity {{ icon('arrow') }}</button></form></section>
<section class="panel settings-card connection-card"><div><span class="eyebrow">GARMIN CONNECT</span><h2>Garmin sync</h2>
{% if connection %}
<p>{{ connection.message }}</p><p class="field-help">{% if connection.last_success %}Last successful sync: {{ connection.last_success|localdate(current_user.timezone, '%d %b %Y %H:%M') }}.{% endif %} {% if connection.pending %}A sync is queued.{% endif %} Automatic sync is {{ 'on (hourly)' if connection.scheduled else 'off' }}.</p>
<form method="post" action="{{ url_for('web.garmin_action', action='sync') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Sync now</button></form>
<form method="post" action="{{ url_for('web.garmin_action', action='schedule') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="enabled" value="{{ 'no' if connection.scheduled else 'yes' }}"><button class="button secondary">{{ 'Pause' if connection.scheduled else 'Enable' }} hourly sync</button></form>
<form method="post" action="{{ url_for('web.garmin_action', action='disconnect') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disconnect Garmin</button></form>
{% endif %}
<p class="muted">Connect or reconnect by uploading the session JSON exported by the local Garmin login helper (garminconnect 0.3.17). Sessions are encrypted on the server. New imports are private. Sync checks your latest 100 activities.</p>
<form method="post" enctype="multipart/form-data" action="{{ url_for('web.garmin_action', action='connect') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Garmin session<input type="file" name="tokens" accept=".json" required></label><button class="button primary">Connect Garmin</button></form>
<p class="field-help">Disconnect deletes the server session and stops scheduled sync. It keeps imported activities and does not revoke other session copies.</p>
</div></section></div>
{% endblock %}