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
+126
View File
@@ -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