Add running fitness history and configurable heart rate zones
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# Completion and release workflow
|
||||||
|
|
||||||
|
For every completed change in this repository, unless the user explicitly asks to
|
||||||
|
keep it local or defer release:
|
||||||
|
|
||||||
|
1. Finish the change and run the appropriate tests and checks.
|
||||||
|
2. Review the diff, then commit the completed work and push it to `origin`.
|
||||||
|
3. Deploy the pushed commit to the existing Dokku app `stride`, following
|
||||||
|
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Back up PostgreSQL before schema changes.
|
||||||
|
4. Verify the deployed Git revision, successful migrations, web and worker health,
|
||||||
|
and the public HTTPS `/health` endpoint. Check the changed feature where feasible.
|
||||||
|
5. Report the commit and deployment result. If release is blocked, state the exact
|
||||||
|
blocker and what has already completed; do not claim deployment succeeded.
|
||||||
|
|
||||||
|
This is the user's standing authorization to commit, push and deploy completed
|
||||||
|
changes. Preserve unrelated work and never commit secrets or database backups.
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ class User(UserMixin, db.Model):
|
|||||||
local_account_key = db.Column(db.String(64), unique=True)
|
local_account_key = db.Column(db.String(64), unique=True)
|
||||||
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||||
hr_bands = db.Column(db.JSON)
|
hr_bands = db.Column(db.JSON)
|
||||||
|
hr_zones = db.Column(db.JSON)
|
||||||
activities = db.relationship(
|
activities = db.relationship(
|
||||||
"Activity", backref="owner", lazy="select", cascade="all, delete-orphan"
|
"Activity", backref="owner", lazy="select", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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,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() }}">
|
<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>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>
|
<label>Fatigue before the run (0–10)<input name="fatigue" type="number" min="0" max="10" value="{{ activity.session_context.get('fatigue', '') }}"></label>
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<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() }}">
|
<form method="post" action="{{ url_for('training.bands') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<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 == '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>
|
<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>
|
<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>
|
</header>
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
"""Private training preferences and session observations."""
|
"""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 flask_login import current_user, login_required
|
||||||
|
|
||||||
from .extensions import db
|
from .extensions import db
|
||||||
@@ -9,6 +9,68 @@ from .models import Activity
|
|||||||
training = Blueprint("training", __name__)
|
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")
|
@training.post("/training/bands")
|
||||||
@login_required
|
@login_required
|
||||||
def bands():
|
def bands():
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Running fitness
|
||||||
|
|
||||||
|
Open **Fitness** in the navigation. Choose automatic or manual five-zone settings in
|
||||||
|
Settings. Existing recordings are scored immediately;
|
||||||
|
no Strava connection or download is needed. Run detail pages show the same score.
|
||||||
|
|
||||||
|
## Effort v1
|
||||||
|
|
||||||
|
Recorded moving minutes in Z1–Z5 have weights 1, 2, 3, 4 and 5 respectively.
|
||||||
|
An exact boundary belongs to the higher zone. Scores use their own scale and do not
|
||||||
|
reproduce Strava numbers. The account's supplied boundaries are 122, 152, 167 and 181
|
||||||
|
bpm (181 belongs to Z5 to avoid leaving a gap after Z4's 180).
|
||||||
|
|
||||||
|
Three setting modes are supported:
|
||||||
|
|
||||||
|
- Automatic: maximum HR is 220 minus age, updated on birthdays, or 190 without a birthday.
|
||||||
|
- Manual maximum: the supplied maximum HR is fixed until edited.
|
||||||
|
- Custom: four manually supplied boundaries are fixed until edited.
|
||||||
|
|
||||||
|
The first two modes generate boundaries at 65%, 80%, 87% and 95% of maximum HR,
|
||||||
|
rounded to the nearest bpm. These are explicit Stride defaults and may differ from
|
||||||
|
Strava's unpublished zone boundary calculation. No peak-HR detection from recordings
|
||||||
|
is performed. Existing three-band Stats settings remain independent.
|
||||||
|
|
||||||
|
The calculation uses persisted `analysis_summary.intervals[].hr_seconds`, which
|
||||||
|
exclude recording breaks, stops and suspect HR. Usable seconds must cover at least
|
||||||
|
80% of the run's timer duration. The remainder is not extrapolated. Without adequate
|
||||||
|
coverage the run remains unscored. Perceived effort is not used
|
||||||
|
as a fallback because it has a different scale.
|
||||||
|
|
||||||
|
## Daily model
|
||||||
|
|
||||||
|
All recorded runs for the owner are grouped by their local calendar day. Daily load
|
||||||
|
is the sum of scored effort. Fitness and fatigue start at zero at the earliest
|
||||||
|
imported run and update every day, including rest days:
|
||||||
|
|
||||||
|
```
|
||||||
|
fitness += (load - fitness) * (1 - exp(-1 / 42))
|
||||||
|
fatigue += (load - fatigue) * (1 - exp(-1 / 7))
|
||||||
|
form = fitness - fatigue
|
||||||
|
```
|
||||||
|
|
||||||
|
The first 126 days (three fitness time constants) are marked provisional. An unscored
|
||||||
|
run marks that day and all subsequent estimates provisional: the model continues
|
||||||
|
using known effort only, while the day's complete load remains unavailable. Missing
|
||||||
|
imports cannot be detected. Other sports are excluded. Values are calculated with
|
||||||
|
full precision, rounded for display. Range controls trim only the displayed series.
|
||||||
|
|
||||||
|
## Historical backfill
|
||||||
|
|
||||||
|
The graph replays the complete stored history on each visit. Imports, deleted runs,
|
||||||
|
timezone changes and zone edits therefore recalculate the history automatically.
|
||||||
|
The open page checks for changes on focus and every five minutes while visible.
|
||||||
|
There is no additional score cache. The `8da41b79e632` database migration adds the
|
||||||
|
private `user.hr_zones` JSON configuration. Run `flask --app run db upgrade` before
|
||||||
|
starting the updated app. Current zones apply to all dates; historical changes in
|
||||||
|
zones are not reconstructed. This retrospective recalculation is a deliberate
|
||||||
|
difference from Strava's documented future-only handling of subsequent zone edits.
|
||||||
|
|
||||||
|
For older imports without current recording summaries, use the existing resumable
|
||||||
|
command (take the normal database backup before operational maintenance):
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m flask --app run backfill-analysis --owner-id OWNER_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
If a current-version summary was damaged or lacks intervals despite usable samples,
|
||||||
|
add `--force` to rebuild it. This regenerates the app's existing analysis summaries,
|
||||||
|
charts and routes as well. Runs without usable original samples remain unscored.
|
||||||
|
|
||||||
|
Local data audit during implementation: 18 running activities, all with current
|
||||||
|
recording intervals; 17 meet the 80% HR coverage requirement (one has 78.5%).
|
||||||
|
The supplied five zones were saved in custom mode and history replayed over 109
|
||||||
|
calendar days. A SQLite backup was taken before applying the configuration migration.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Personal five-zone fitness settings."""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "8da41b79e632"
|
||||||
|
down_revision = "7c6e32b491ad"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table("user") as batch:
|
||||||
|
batch.add_column(sa.Column("hr_zones", sa.JSON()))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("user") as batch:
|
||||||
|
batch.drop_column("hr_zones")
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from math import exp
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from test_app import login
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.fitness import effort, history
|
||||||
|
from app.heart_rate import zones
|
||||||
|
from app.models import Activity, User
|
||||||
|
|
||||||
|
pytest_plugins = ("test_app",)
|
||||||
|
|
||||||
|
|
||||||
|
def run(seconds=600, histogram=None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
sport="running",
|
||||||
|
duration_s=seconds,
|
||||||
|
analysis_summary={"intervals": [{"hr_seconds": histogram or {}}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effort_boundaries_weights_and_coverage():
|
||||||
|
scored = effort(run(histogram={"120": 200, "140": 200, "160": 200}), [140, 160])
|
||||||
|
assert scored["score"] == 20
|
||||||
|
assert scored["coverage"] == 100
|
||||||
|
assert effort(run(histogram={"140": 479}), [140, 160])["score"] is None
|
||||||
|
assert effort(run(histogram={"140": 480}), [140, 160])["score"] == 16
|
||||||
|
assert effort(run(histogram={"140": 600}), None)["score"] is None
|
||||||
|
assert effort(run(histogram={"140": 600}), None)["coverage"] == 100
|
||||||
|
assert effort(run(histogram={"250": 600}), [140, 160])["score"] is None
|
||||||
|
five = effort(
|
||||||
|
run(histogram={"121": 120, "122": 120, "152": 120, "167": 120, "181": 120}),
|
||||||
|
[122, 152, 167, 181],
|
||||||
|
)
|
||||||
|
assert five["score"] == 30
|
||||||
|
assert five["zone_minutes"] == [2, 2, 2, 2, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatic_zones_update_on_birthday_manual_overrides():
|
||||||
|
user = SimpleNamespace(
|
||||||
|
timezone="Australia/Sydney", hr_zones={"mode": "auto", "birthday": "1990-10-22"}
|
||||||
|
)
|
||||||
|
assert zones(user, date(2026, 10, 21))["max_hr"] == 185
|
||||||
|
assert zones(user, date(2026, 10, 22))["max_hr"] == 184
|
||||||
|
user.hr_zones = {"mode": "max", "max_hr": 195, "birthday": "1990-10-22"}
|
||||||
|
assert zones(user, date(2026, 10, 22))["max_hr"] == 195
|
||||||
|
user.hr_zones = None
|
||||||
|
assert zones(user)["max_hr"] == 190
|
||||||
|
user.hr_zones = {"mode": "custom", "boundaries": [122, 152, 167, 181]}
|
||||||
|
assert zones(user)["labels"] == ["0–121", "122–151", "152–166", "167–180", "181+"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_local_days_sum_decay_missing_and_owner_scope(app):
|
||||||
|
with app.app_context():
|
||||||
|
db.session.delete(db.session.get(Activity, 1))
|
||||||
|
user = db.session.get(User, 1)
|
||||||
|
user.hr_zones = {"mode": "custom", "boundaries": [140, 160, 180, 200]}
|
||||||
|
for key, owner, hour, histogram in [
|
||||||
|
("a", 1, 13, {"140": 600}),
|
||||||
|
("b", 1, 14, {"140": 600}),
|
||||||
|
("other", 2, 14, {"160": 600}),
|
||||||
|
("missing", 1, 15, {}),
|
||||||
|
]:
|
||||||
|
row = Activity(
|
||||||
|
owner_id=owner,
|
||||||
|
source_key=key,
|
||||||
|
title=key,
|
||||||
|
sport="running",
|
||||||
|
started_at=datetime(2026, 1, 1, hour, tzinfo=UTC),
|
||||||
|
duration_s=600,
|
||||||
|
analysis_summary=run(histogram=histogram).analysis_summary,
|
||||||
|
)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
data = history(user, date(2026, 1, 3))
|
||||||
|
assert len(data["runs"]) == 3
|
||||||
|
assert data["series"][0]["day"] == "2026-01-02"
|
||||||
|
first, second = data["series"]
|
||||||
|
assert first["load"] is None
|
||||||
|
assert first["missing"] == 1
|
||||||
|
assert first["fitness"] == round(40 * (1 - exp(-1 / 42)), 2)
|
||||||
|
assert first["fatigue"] == round(40 * (1 - exp(-1 / 7)), 2)
|
||||||
|
assert first["form"] == round(40 * (exp(-1 / 7) - exp(-1 / 42)), 2)
|
||||||
|
assert second["load"] == 0
|
||||||
|
assert second["fitness"] == round(40 * (1 - exp(-1 / 42)) * exp(-1 / 42), 2)
|
||||||
|
assert second["provisional"]
|
||||||
|
assert history(user, date(2026, 1, 3)) == data
|
||||||
|
user.hr_zones = {"mode": "custom", "boundaries": [160, 180, 200, 220]}
|
||||||
|
recalculated = history(user, date(2026, 1, 3))
|
||||||
|
assert recalculated["series"][0]["fitness"] == round(20 * (1 - exp(-1 / 42)), 2)
|
||||||
|
assert history(user, date(2026, 1, 1))["series"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fitness_page_private_and_renders_history(app):
|
||||||
|
client = app.test_client()
|
||||||
|
assert client.get("/fitness").status_code == 302
|
||||||
|
assert client.get("/fitness/data").status_code == 302
|
||||||
|
login(client)
|
||||||
|
response = client.get("/fitness")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(client.get("/fitness/data").json["runs"]) == 1
|
||||||
|
assert b"A quiet morning run" in response.data
|
||||||
|
with app.app_context():
|
||||||
|
db.session.get(User, 1).hr_bands = [140, 160]
|
||||||
|
db.session.commit()
|
||||||
|
assert b"fitness-chart" in client.get("/fitness").data
|
||||||
|
assert b"Run effort:" in client.get("/activities/1").data
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_settings_validation_persistence_and_privacy(app):
|
||||||
|
client = app.test_client()
|
||||||
|
assert client.post("/training/zones", data={"mode": "auto"}).status_code == 302
|
||||||
|
login(client)
|
||||||
|
valid = {"mode": "custom", "z2": "122", "z3": "152", "z4": "167", "z5": "181"}
|
||||||
|
assert client.post("/training/zones", data=valid).status_code == 302
|
||||||
|
assert client.post("/training/zones", data={**valid, "z3": "100"}).status_code == 400
|
||||||
|
assert client.post("/training/zones", data={"mode": "max", "max_hr": "999"}).status_code == 400
|
||||||
|
assert (
|
||||||
|
client.post("/training/zones", data={"mode": "auto", "birthday": "2099-01-01"}).status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
assert db.session.get(User, 1).hr_zones["boundaries"] == [122, 152, 167, 181]
|
||||||
|
assert db.session.get(User, 2).hr_zones is None
|
||||||
|
assert b"Manual zone boundaries" in client.get("/settings").data
|
||||||
Reference in New Issue
Block a user