Add running fitness history and configurable heart rate zones

This commit is contained in:
Peter Stockings
2026-10-22 23:20:35 +11:00
parent b8eb6d10f8
commit 95e751d05c
15 changed files with 534 additions and 2 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Running load v1: personal five-zone minutes, with daily exponential decay."""
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from math import exp
from zoneinfo import ZoneInfo
from sqlalchemy.orm import undefer
from .extensions import db
from .heart_rate import zones
from .models import Activity
def effort(activity, bands):
"""Require 80% timer-time HR coverage; never extrapolate missing HR."""
result = {"score": None, "coverage": 0, "reason": "Set your HR zones in Settings."}
if activity.sport != "running":
result["reason"] = "Running activities only."
return result
units = (activity.analysis_summary or {}).get("intervals", [])
seconds = [0.0] * (len(bands) + 1 if bands else 5)
for unit in units:
for hr, duration in unit.get("hr_seconds", {}).items():
hr = float(hr)
if 30 <= hr <= 240:
band = sum(hr >= boundary for boundary in bands) if bands else 0
seconds[band] += duration
coverage = sum(seconds) / activity.duration_s if activity.duration_s > 0 else 0
result["coverage"] = round(min(coverage, 1) * 100, 1)
if not bands:
return result
result["reason"] = "Insufficient recorded HR (80% timer-time coverage required)."
if coverage >= 0.8:
result.update(
score=sum(t * w for w, t in enumerate(seconds, 1)) / 60,
reason="Recorded HR · zone weights 1 / 2 / 3 / 4 / 5",
)
result["zone_minutes"] = [round(t / 60, 2) for t in seconds]
return result
def history(user, today=None):
zone = ZoneInfo(user.timezone)
today = today or datetime.now(zone).date()
zone_settings = zones(user, today)
rows = db.session.scalars(
db.select(Activity)
.where(Activity.owner_id == user.id, Activity.sport == "running")
.options(undefer(Activity.analysis_summary))
.order_by(Activity.started_at, Activity.id)
).all()
days = defaultdict(list)
runs = []
for row in rows:
day = row.started_at.replace(tzinfo=UTC).astimezone(zone).date()
if day > today:
continue
scored = effort(row, zone_settings["boundaries"])
run = {"id": row.id, "title": row.title, "day": day.isoformat(), **scored}
days[day].append(run)
runs.append(run)
series = []
fitness = fatigue = 0.0
incomplete = False
if days:
day = min(days)
start = day
while day <= today:
entries = days.get(day, [])
missing = sum(r["score"] is None for r in entries)
incomplete |= missing > 0
load = sum(r["score"] for r in entries if r["score"] is not None)
fitness += (load - fitness) * (1 - exp(-1 / 42))
fatigue += (load - fatigue) * (1 - exp(-1 / 7))
series.append(
{
"day": day.isoformat(),
"load": None if missing else load,
"fitness": round(fitness, 2),
"fatigue": round(fatigue, 2),
"form": round(fitness - fatigue, 2),
"missing": missing,
"provisional": (day - start).days < 126 or incomplete,
}
)
day += timedelta(days=1)
return {
"series": series,
"runs": list(reversed(runs)),
"unscored": sum(r["score"] is None for r in runs),
"zones": zone_settings,
}
+46
View File
@@ -0,0 +1,46 @@
"""Explicit automatic and manual five-zone configuration for running load."""
from datetime import date, datetime
from itertools import pairwise
from zoneinfo import ZoneInfo
# Published as Stride's formula, not an exact reproduction of Strava's zones.
ZONE_FRACTIONS = (0.65, 0.80, 0.87, 0.95)
def zones(user, today=None):
today = today or datetime.now(ZoneInfo(user.timezone)).date()
config = user.hr_zones or {"mode": "auto"}
mode = config.get("mode", "auto")
maximum = None
if mode == "custom":
boundaries = config["boundaries"]
source = "Manual zone boundaries"
else:
maximum = config.get("max_hr") if mode == "max" else None
if maximum is not None:
source = "Manual maximum HR"
elif config.get("birthday"):
birthday = date.fromisoformat(config["birthday"])
age = (
today.year
- birthday.year
- ((today.month, today.day) < (birthday.month, birthday.day))
)
maximum = 220 - age
source = "Automatic · 220 minus age · updates on your birthday"
else:
maximum = 190
source = "Automatic · default maximum 190 bpm (birthday not set)"
boundaries = [round(maximum * fraction) for fraction in ZONE_FRACTIONS]
labels = [f"0–{boundaries[0] - 1}"]
labels += [f"{a}–{b - 1}" for a, b in pairwise(boundaries)]
labels += [f"{boundaries[-1]}+"]
return {
"boundaries": boundaries,
"source": source,
"max_hr": maximum,
"mode": mode,
"labels": labels,
"config": config,
}
+1
View File
@@ -17,6 +17,7 @@ class User(UserMixin, db.Model):
local_account_key = db.Column(db.String(64), unique=True)
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
hr_bands = db.Column(db.JSON)
hr_zones = db.Column(db.JSON)
activities = db.relationship(
"Activity", backref="owner", lazy="select", cascade="all, delete-orphan"
)
+16
View File
@@ -0,0 +1,16 @@
.fitness-page {display: grid; gap: 20px;}
.fitness-page > .eyebrow {margin-bottom: -12px;}
.fitness-page > p {color: var(--muted); max-width: 960px;}
.fitness-page a {text-decoration: underline; text-underline-offset: 3px;}
.fitness-page .stats-grid {grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0;}
.fitness-page .chart-wrap {height: 360px;}
.fitness-page .methodology {margin-top: 0;}
.fitness-page table {width: 100%; border-collapse: collapse; text-align: left;}
.fitness-page th, .fitness-page td {padding: 14px 24px; border-bottom: 1px solid var(--line);}
.fitness-page th {color: var(--muted); font-size: 11px;}
.fitness-page td:first-child {white-space: nowrap; font-variant-numeric: tabular-nums;}
@media (max-width: 680px) {
.fitness-page .stats-grid {grid-template-columns: 1fr;}
.fitness-page .chart-wrap {height: 280px;}
.fitness-page th, .fitness-page td {padding: 12px;}
}
+45
View File
@@ -0,0 +1,45 @@
(() => {
const source = document.getElementById('fitness-data');
const initial = JSON.parse(source.textContent);
let refreshing = false;
async function refresh() {
if (document.hidden || refreshing) return;
refreshing = true;
try {
const response = await fetch(source.dataset.url, {cache: 'no-store', headers: {Accept: 'application/json'}});
if (response.ok && response.headers.get('content-type')?.includes('application/json')) {
const next = await response.json();
if (JSON.stringify(next.series) !== JSON.stringify(initial.series) ||
JSON.stringify(next.runs) !== JSON.stringify(initial.runs) ||
JSON.stringify(next.zones) !== JSON.stringify(initial.zones)) window.location.reload();
}
} catch { /* Keep the current chart available while offline. */ }
finally { refreshing = false; }
}
window.addEventListener('focus', refresh);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
window.setInterval(refresh, 300000);
const canvas = document.getElementById('fitness-chart');
if (!canvas) return;
const series = initial.series;
const keys = ['fitness', 'fatigue', 'form'];
const colors = ['#67cbb8', '#f48459', '#b8a1f0'];
const chart = new Chart(canvas, {
type: 'line',
data: {labels: [], datasets: keys.map((key, i) => ({label: key[0].toUpperCase() + key.slice(1), data: [], borderColor: colors[i], pointRadius: 0, borderWidth: 2}))},
options: {responsive: true, maintainAspectRatio: false, interaction: {mode: 'index', intersect: false}, plugins: {legend: {labels: {color: '#eeeae4'}}, tooltip: {callbacks: {afterBody(items) {
const row = visible[items[0].dataIndex];
return [row.missing ? `${row.missing} unscored run(s); known effort only` : `Daily effort: ${row.load.toFixed(1)}`, ...(row.provisional ? ['Provisional estimate'] : [])];
}}}}, scales: {x: {ticks: {maxTicksLimit: 8, color: '#9ba4a5'}, grid: {color: '#303638'}}, y: {ticks: {color: '#9ba4a5'}, grid: {color: '#303638'}}}}
});
let visible = series;
const range = document.getElementById('fitness-range');
function update() {
visible = range.value === 'all' ? series : series.slice(-Number(range.value));
chart.data.labels = visible.map(row => row.day);
keys.forEach((key, i) => {chart.data.datasets[i].data = visible.map(row => row[key]);});
chart.update();
}
range.addEventListener('change', update);
update();
})();
+12
View File
@@ -0,0 +1,12 @@
{% set zones = fitness_zones(current_user) %}
<section class="panel settings-card"><span class="eyebrow">RUNNING FITNESS</span><h2>Heart-rate zones</h2>
<p>{{ zones.source }}{% if zones.max_hr %} · {{ zones.max_hr }} bpm{% endif %}</p>
<p>{% for label in zones.labels %}Z{{ loop.index }}: {{ label }} bpm{{ ' · ' if not loop.last }}{% endfor %}</p>
<form method="post" action="{{ url_for('training.zones') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Zone calculation<select name="mode"><option value="auto" {{ 'selected' if zones.mode == 'auto' }}>Automatic from age</option><option value="max" {{ 'selected' if zones.mode == 'max' }}>Manual maximum heart rate</option><option value="custom" {{ 'selected' if zones.mode == 'custom' }}>Manual zone boundaries</option></select></label>
<label>Birthday (optional, for automatic updates)<input type="date" name="birthday" value="{{ zones.config.get('birthday', '') }}"></label>
<label>Maximum HR (for manual maximum mode)<input type="number" name="max_hr" min="100" max="230" value="{{ zones.config.get('max_hr', '') }}"></label>
{% for boundary in zones.boundaries %}<label>Z{{ loop.index + 1 }} starts at (bpm, for manual zones)<input type="number" name="z{{ loop.index + 1 }}" min="30" max="230" value="{{ boundary }}" required></label>{% endfor %}
<p class="field-help">Automatic mode uses 220 minus age, or 190 bpm without a birthday. Zones begin at 65%, 80%, 87% and 95% of maximum HR (rounded to the nearest bpm). These are Stride's published defaults, which may differ from Strava. Age-based zones update on your birthday. Manual settings remain fixed until edited.</p>
<p class="field-help">New runs and rest days update the fitness graph automatically. Zone edits recalculate all historical scores using the current zones. Existing three-band Stats settings are independent.</p>
<button class="button primary">Save fitness zones</button></form></section>
+1 -1
View File
@@ -1,4 +1,4 @@
{% if owner_view %}<section class="panel settings-card"><span class="eyebrow">PRIVATE SESSION CONTEXT</span><h2>How did this run feel?</h2>
{% if owner_view %}{% set effort = run_effort(activity, fitness_zones(current_user).boundaries) %}<section class="panel settings-card"><h2>Run effort: {{ '%.1f'|format(effort.score) if effort.score is not none else 'Unscored' }}</h2><p>{{ effort.reason }} · HR coverage {{ effort.coverage }}%. <a href="{{ url_for('training.fitness') }}">View fitness history</a></p></section><section class="panel settings-card"><span class="eyebrow">PRIVATE SESSION CONTEXT</span><h2>How did this run feel?</h2>
<form method="post" action="{{ url_for('training.context', activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Perceived effort (1–10)<input name="perceived_effort" type="number" min="1" max="10" value="{{ activity.session_context.get('perceived_effort', '') }}"></label>
<label>Fatigue before the run (0–10)<input name="fatigue" type="number" min="0" max="10" value="{{ activity.session_context.get('fatigue', '') }}"></label>
+1
View File
@@ -1,3 +1,4 @@
{% include '_fitness_zones.html' %}
<section class="panel settings-card"><span class="eyebrow">EFFORT DISTRIBUTION</span><h2>Your heart-rate bands</h2>
<p class="field-help">Set two boundaries for your lower, middle and upper effort bands. Use your own chosen thresholds; these labels do not infer physiological zones.</p>
<form method="post" action="{{ url_for('training.bands') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+1
View File
@@ -14,6 +14,7 @@
<a class="brand" href="{{ url_for('web.dashboard') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<nav class="header-nav" aria-label="Main navigation"><a class="{{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">Dashboard</a><a class="{{ 'active' if request.endpoint in ('web.friends', 'web.profile') }}" href="{{ url_for('web.friends') }}">Friends</a></nav>
<a class="stats-nav {{ 'active' if request.endpoint == 'web.stats' }}" href="{{ url_for('web.stats') }}">Stats</a>
<a class="stats-nav {{ 'active' if request.endpoint == 'training.fitness' }}" href="{{ url_for('training.fitness') }}">Fitness</a>
<a class="stats-nav {{ 'active' if request.blueprint == 'planner' }}" href="{{ url_for('planner.index') }}">Routes</a>
<div class="header-actions">{% if request.endpoint != 'web.profile' %}<a class="button primary header-import" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import run</a>{% endif %}<details class="account-dropdown"><summary class="avatar" aria-label="Account menu" title="Account menu">{{ current_user.display_name[:1]|upper }}</summary><div class="account-dropdown-menu"><strong>{{ current_user.display_name }}</strong><a href="{{ url_for('web.settings') }}">Settings & Garmin sync</a><a href="{{ url_for('web.settings') }}">Import runs</a><form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">Sign out</button></form></div></details></div>
</header>
+20
View File
@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}Running fitness · Stride{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='fitness.css') }}">{% endblock %}
{% block content %}
<div class="fitness-page">
<span class="eyebrow">YOUR TRAINING OVER TIME</span><h1>Running fitness</h1>
<p>Accumulated running effort, fatigue and form. Scores describe training load, alongside your performance trends.</p>
<p>{{ data.zones.source }} · <a href="{{ url_for('web.settings') }}">Edit fitness zones</a></p>
<p>{% for label in data.zones.labels %}Z{{ loop.index }}: {{ label }} bpm{{ ' · ' if not loop.last }}{% endfor %}</p>
{% if data.series %}
{% set latest = data.series[-1] %}
<div class="stats-grid">{% for key in ['fitness', 'fatigue', 'form'] %}<article class="stat-card"><div class="stat-label">{{ key|upper }}</div><div class="stat-value">{{ '%.1f'|format(latest[key]) }}</div><p>{{ 'Provisional estimate' if latest.provisional else 'Running load estimate' }}</p></article>{% endfor %}</div>
<section class="panel"><div class="section-head"><h2>Fitness, fatigue & form</h2><label>Show <select id="fitness-range"><option value="90">90 days</option><option value="365">1 year</option><option value="all" selected>All history</option></select></label></div><div class="chart-wrap"><canvas id="fitness-chart" role="img" aria-label="Daily running fitness, fatigue and form"></canvas></div></section>
{% endif %}
<p>{{ data.runs|length }} historical runs · {{ data.unscored }} unscored. Runs missing sufficient HR remain unscored. On those days the model uses only known effort, so subsequent values may underestimate load.</p>
<details class="panel methodology"><summary>How these scores work</summary><p>Effort v1 adds recorded moving minutes in Z1–Z5 with weights 1, 2, 3, 4 and 5. At each boundary HR belongs to the higher zone. At least 80% of timer time must have usable HR; missing time is not extrapolated. Recording breaks, stops and suspect HR are excluded by the recording analysis.</p><p>Each calendar day in your timezone updates fitness by (daily effort − previous fitness) × (1 − exp(−1/42)); fatigue uses 7 instead of 42. Form is same-day fitness minus fatigue. Multiple runs are summed; rest days contribute zero. The full history is calculated before choosing the visible range.</p><p>The model starts at zero at your earliest imported run; its first 126 days are provisional. Missing runs and unscored sessions can understate load. Only running is included. Changing HR zones recalculates all history with those zones. These are Stride estimates, with their own scale, rather than exact Strava scores.</p></details>
<section class="panel"><div class="section-head"><h2>Run effort</h2></div><div class="activity-table-wrap"><table><thead><tr><th>Date</th><th>Run</th><th>Effort</th><th>HR coverage</th></tr></thead><tbody>{% for run in data.runs %}<tr><td>{{ run.day }}</td><td><a href="{{ url_for('web.activity', activity_id=run.id) }}">{{ run.title }}</a></td><td title="{{ run.reason }}">{{ '%.1f'|format(run.score) if run.score is not none else 'Unscored' }}</td><td>{{ run.coverage }}%</td></tr>{% else %}<tr><td colspan="4">Import runs to build your fitness history.</td></tr>{% endfor %}</tbody></table></div></section>
</div>
{% endblock %}
{% block scripts %}<script id="fitness-data" type="application/json" data-url="{{ url_for('training.fitness_data') }}">{{ data|tojson }}</script><script src="{{ url_for('static', filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static', filename='fitness.js') }}"></script>{% endblock %}
+63 -1
View File
@@ -1,6 +1,6 @@
"""Private training preferences and session observations."""
from flask import Blueprint, abort, flash, redirect, request, url_for
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from .extensions import db
@@ -9,6 +9,68 @@ from .models import Activity
training = Blueprint("training", __name__)
@training.get("/fitness")
@login_required
def fitness():
from .fitness import history
return render_template("fitness.html", data=history(current_user))
@training.get("/fitness/data")
@login_required
def fitness_data():
from .fitness import history
return jsonify(history(current_user))
@training.app_context_processor
def fitness_helpers():
from .fitness import effort
from .heart_rate import zones
return {"run_effort": effort, "fitness_zones": zones}
@training.post("/training/zones")
@login_required
def zones():
from datetime import date, datetime
from zoneinfo import ZoneInfo
mode = request.form.get("mode")
if mode not in {"auto", "max", "custom"}:
abort(400)
config = {"mode": mode}
try:
birthday = request.form.get("birthday", "").strip()
if birthday:
birthday_date = date.fromisoformat(birthday)
today = datetime.now(ZoneInfo(current_user.timezone)).date()
age = today.year - birthday_date.year - ((today.month, today.day) <
(birthday_date.month, birthday_date.day))
if not 5 <= age <= 110:
abort(400)
config["birthday"] = birthday
if mode == "max":
maximum = int(request.form.get("max_hr", ""))
if not 100 <= maximum <= 230:
abort(400)
config["max_hr"] = maximum
if mode == "custom":
boundaries = [int(request.form.get(f"z{i}", "")) for i in range(2, 6)]
if not 30 <= boundaries[0] < boundaries[1] < boundaries[2] < boundaries[3] <= 230:
abort(400)
config["boundaries"] = boundaries
except (ValueError, TypeError):
abort(400)
current_user.hr_zones = config
db.session.commit()
flash("Fitness zones saved. Your full fitness history recalculates automatically.")
return redirect(url_for("training.fitness"))
@training.post("/training/bands")
@login_required
def bands():