Add saved analysis workspace and live dashboard statistics

This commit is contained in:
Peter Stockings
2026-10-03 12:27:20 +10:00
parent 5ede1a01d2
commit e6e2f22479
31 changed files with 1986 additions and 131 deletions
+333
View File
@@ -0,0 +1,333 @@
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import event
from test_app import login
from app.display import persist_analysis
from app.effort_segments import analyze
from app.extensions import db
from app.models import Activity
from app.stats import explore, parse_query, query_text
from app.stats_dsl import QueryError
from app.stats_presets import PRESETS
pytest_plugins = ("test_app",)
def parsed(text):
return parse_query({"query": text})
def activity(key, instant, hr=140, owner=1, samples=None):
return Activity(
owner_id=owner,
source_key=key,
title=key,
sport="running",
started_at=instant,
distance_m=10000,
duration_s=3300,
elevation_m=30,
avg_hr=hr,
samples=samples or [],
)
@pytest.mark.parametrize("preset", PRESETS, ids=[p["name"] for p in PRESETS])
def test_presets_roundtrip_and_execute(app, preset):
q = parsed(preset["query"])
assert parsed(query_text(q)) == q
with app.app_context():
result = explore(1, "Australia/Sydney", q)
assert result["source"] == q["source"]
client = app.test_client()
login(client)
assert client.get("/stats", query_string={"query": preset["query"]}).status_code == 200
def test_library_crud_pin_privacy_and_live_results(app):
client = app.test_client()
assert client.get("/stats/analyses").status_code == 302
login(client)
source = "runs\nduring all time\nsummarize distance\nby month\ncompare none"
response = client.post(
"/stats/analyses",
json={
"name": "Volume",
"query": source,
"pinned": True,
"collection": "Training",
"notes": "Check consistency",
},
)
assert response.status_code == 201
row = response.json["analysis"]
assert row["pinned"] and row["collection"] == "Training"
analysis_id = row["id"]
assert client.get("/stats/analyses").json["analyses"][0]["id"] == analysis_id
assert client.get(f"/stats?analysis={analysis_id}").status_code == 200
assert b"Volume" in client.get("/dashboard").data
result = client.get(f"/stats/analyses/{analysis_id}/results")
assert result.json["results"]["count"] == 1
assert "no-store" in result.headers["Cache-Control"]
with app.app_context():
db.session.add(activity("new", datetime(2026, 9, 29, tzinfo=UTC)))
db.session.commit()
assert client.get(f"/stats/analyses/{analysis_id}/results").json["results"]["count"] == 2
other = app.test_client()
login(other, "other")
assert other.get("/stats/analyses").json["analyses"] == []
for method, path in (
("get", f"/stats?analysis={analysis_id}"),
("get", f"/stats/analyses/{analysis_id}/results"),
("patch", f"/stats/analyses/{analysis_id}"),
("delete", f"/stats/analyses/{analysis_id}"),
):
assert getattr(other, method)(path, json={"name": "Stolen"}).status_code == 404
assert other.post("/stats/analyses/order", json={"ids": [analysis_id]}).status_code == 400
assert (
client.patch(
f"/stats/analyses/{analysis_id}", json={"width": "full", "view": "value"}
).json["analysis"]["width"]
== "full"
)
assert (
client.post("/stats/analyses/order", json={"ids": [analysis_id, analysis_id]}).status_code
== 400
)
assert client.post("/stats/analyses/order", json={"ids": [analysis_id]}).status_code == 200
assert client.delete(f"/stats/analyses/{analysis_id}").status_code == 204
assert client.get(f"/stats/analyses/{analysis_id}/results").status_code == 404
@pytest.mark.parametrize(
"payload",
[
None,
[],
{"name": ""},
{"name": "x", "query": "runs"},
{"name": "x", "query": "runs\nsummarize pace", "pinned": "yes"},
{"name": "x", "query": "runs\nsummarize pace", "width": []},
],
)
def test_library_rejects_invalid_payloads(app, payload):
client = app.test_client()
login(client)
assert client.post("/stats/analyses", json=payload).status_code == 400
def test_library_mutations_require_csrf(app):
client = app.test_client()
login(client)
app.config["WTF_CSRF_ENABLED"] = True
assert (
client.post(
"/stats/analyses", json={"name": "x", "query": "runs\nsummarize pace"}
).status_code
== 400
)
assert client.post("/stats/analyses/order", json={"ids": []}).status_code == 400
def test_named_summary_with_commas_quotes_and_reference_roundtrip():
q = parsed('runs\nsummarize weighted_mean (heart_rate) as "HR, \\"easy\\""')
assert q["labels"] == {"weighted_mean(heart_rate)": 'HR, "easy"'}
assert parsed(query_text(q)) == q
ref = parsed("runs\nsummarize heart_rate\nreference heart_rate = 150 bpm")
assert ref["reference"]["value"] == 150
assert parsed(query_text(ref)) == ref
def test_histogram_drilldown_and_quartiles_cover_exact_inputs(app):
with app.app_context():
db.session.add_all(
[
activity("ten", datetime(2026, 9, 29, tzinfo=UTC)),
activity("ten-more", datetime(2026, 9, 28, tzinfo=UTC)),
]
)
db.session.commit()
q = parsed(
"runs\nduring 2026-09-01 to 2026-09-30\nsummarize distance\nby all\ncompare none\nchart histogram"
)
result = explore(1, "Australia/Sydney", q)
histogram = result["chart"]["series"][0]["histogram"]
ids = []
for bucket, count in zip(histogram["buckets"], histogram["counts"], strict=True):
selected = explore(1, "Australia/Sydney", q, selected_bucket=bucket)
assert selected["evidence_count"] == count
ids.extend(row["id"] for row in selected["rows"])
assert len(ids) == len(set(ids)) == 3
q["chart"] = "box"
box = explore(1, "Australia/Sydney", q)["chart"]["series"][0]["boxes"][0]
assert box == {"low": 5, "q1": 7.5, "median": 10, "q3": 10, "high": 10, "count": 3}
def test_lap_source_does_not_inherit_parent_sensor_or_terrain(app):
with app.app_context():
q = parsed(
"laps\nduring 2026-09-30 to 2026-09-30\nsummarize pace, heart_rate, elevation\nby run\ncompare none"
)
result = explore(1, "Australia/Sydney", q)
assert result["count"] == 1
assert result["rows"][0]["raw_values"] == [360, 140, None]
assert result["cards"][2]["coverage"] == 0
def test_matched_baseline_flags_reset_at_missing_bucket(app):
with app.app_context():
start = datetime(2026, 9, 7, tzinfo=UTC)
rows = [
activity(f"base-{i}", start - timedelta(days=20 - i), 139 + i % 3) for i in range(12)
]
# An unmatched fast run and another owner's run cannot affect the baseline.
fast = activity("fast", start - timedelta(days=4), 200)
fast.duration_s = 2400
rows.extend([fast, activity("other-owner", start - timedelta(days=3), 230, owner=2)])
for week in (0, 1, 3, 4):
rows.extend(
activity(f"high-{week}-{i}", start + timedelta(days=week * 7 + i), 160)
for i in range(3)
)
db.session.add_all(rows)
db.session.commit()
q = parsed(
"runs\nduring 2026-09-07 to 2026-10-11\nwhere pace between 5:20 and 5:40 /km\nsummarize weighted_mean(heart_rate)\nby week\ncompare none\nbaseline previous 30 days\nflag above baseline 1.5 sd min 3 runs sustained 2 buckets"
)
result = explore(1, "Australia/Sydney", q)
assert result["insights"][0]["count"] == 12
assert result["insights"][0]["baseline"] == 140
assert result["chart"]["series"][0]["baseline"]["flags"] == [
False,
True,
False,
False,
True,
]
assert len(result["insights"][0]["flagged"]) == 2
def test_rolling_window_uses_history_before_filters(app):
with app.app_context():
db.session.add_all(
[
activity("before", datetime(2026, 9, 1, tzinfo=UTC), 120),
activity("target", datetime(2026, 9, 8, tzinfo=UTC), 160),
]
)
db.session.commit()
q = parsed(
"runs\nduring 2026-09-08 to 2026-09-08\nlet hr_14 = rolling_mean(heart_rate, 14 days)\nwhere heart_rate >= 150 bpm\nsummarize hr_14\nby run\ncompare none"
)
assert explore(1, "Australia/Sydney", q)["rows"][0]["raw_values"] == [140]
assert parsed(query_text(q)) == q
def test_cohorts_partition_without_double_counting(app):
with app.app_context():
db.session.add(activity("hard", datetime(2026, 9, 29, tzinfo=UTC), 160))
db.session.commit()
q = parsed(
'runs\nduring 2026-09-01 to 2026-09-30\nsummarize sum(distance)\nby all\ncompare none\ncohort "Easy" where heart_rate <= 145 bpm\ncohort "All HR" where heart_rate >= 100 bpm\nchart stacked'
)
assert parsed(query_text(q)) == q
cohorts = explore(1, "Australia/Sydney", q)["chart"]["cohorts"]
assert [c["values"] for c in cohorts] == [[5], [10], [None]]
def recording(hr_first=140, hr_second=160):
return [
{
"time": i,
"distance": i * 3,
"speed": 3,
"heart_rate": hr_first if i <= 300 else hr_second,
"elevation": 10,
}
for i in range(601)
]
def test_segments_and_drift_full_resolution():
effort, segments = analyze(recording())
assert len(segments) == 2
assert [s["duration"] for s in segments] == [300, 300]
assert [s["heart_rate"] for s in segments] == [140, 160]
assert effort["pace_variability"] == pytest.approx(0)
assert effort["heart_rate_drift"] == pytest.approx(12.5)
assert effort["sensor_coverage"] == 100
missing = recording()
for row in missing[200:]:
row["heart_rate"] = None
row.pop("elevation")
summary, _ = analyze(missing)
assert summary["heart_rate_drift"] is None
assert summary["elevation"] is None
assert summary["sensor_coverage"] < 40
reset = [{"time": 0, "distance": 10}, {"time": 11, "distance": 50}, {"time": 12, "distance": 0}]
assert analyze(reset)[1] == []
def test_segment_queries_use_compact_owner_scoped_data(app):
with app.app_context():
run = activity("recorded", datetime(2026, 9, 29, tzinfo=UTC), samples=recording())
other = activity(
"secret", datetime(2026, 9, 29, tzinfo=UTC), owner=2, samples=recording(200, 220)
)
db.session.add_all([run, other])
persist_analysis(run)
persist_analysis(other)
db.session.commit()
queries = []
def capture(_conn, _cursor, statement, _params, _context, _many):
queries.append(statement)
event.listen(db.engine, "before_cursor_execute", capture)
try:
q = parsed(
"segments\nduring 2026-09-29 to 2026-09-29\nwhere pace_variability <= 5 %\nand sensor_coverage >= 80 %\nsummarize heart_rate, pace\nby run\ncompare none"
)
result = explore(1, "Australia/Sydney", q)
finally:
event.remove(db.engine, "before_cursor_execute", capture)
assert result["count"] == 2
assert {row["id"] for row in result["rows"]} == {run.id}
assert {row["raw_values"][0] for row in result["rows"]} == {140, 160}
assert all("activity.samples" not in query for query in queries)
assert parsed(query_text(q)) == q
def test_previous_year_comparison(app):
with app.app_context():
db.session.add(activity("prior-year", datetime(2025, 9, 30, tzinfo=UTC), 150))
db.session.commit()
q = parsed(
"runs\nduring 2026-09-01 to 2026-09-30\nsummarize heart_rate\nby month\ncompare previous year"
)
assert parsed(query_text(q)) == q
result = explore(1, "Australia/Sydney", q)
assert result["prior_count"] == 1
assert result["cards"][0]["previous"] == "150.0"
assert result["previous_end"].isoformat() == "2025-09-30"
@pytest.mark.parametrize(
"source",
[
"runs\nsummarize pace\nflag above baseline 2 sd min 3 runs sustained 2 buckets",
"runs\nsummarize pace\nbaseline previous 1 days",
"runs\nsummarize pace\nby weekday\nbaseline previous 90 days",
"runs\nsummarize pace\nchart heatmap",
"runs\nsummarize pace\nchart stacked",
"runs\nsummarize pace\ncolor by distance",
"runs\nsummarize pace\nreference heart_rate = 150 bpm",
"segments\nsummarize rolling_7d_distance",
"runs\nlet bad = rolling_mean(password, 10 days)\nsummarize bad",
],
)
def test_invalid_analysis_features(source):
with pytest.raises(QueryError):
parsed(source)
+3 -1
View File
@@ -389,7 +389,9 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app):
row = db.session.get(Activity, row_id)
assert row.efforts_version == EFFORTS_VERSION
assert row.best_efforts[0]["seconds"] == 300
assert row.analysis_version == 1
from app.display import ANALYSIS_VERSION
assert row.analysis_version == ANALYSIS_VERSION
assert row.analysis_summary["summary"]["samples"] == 2
assert activity_from_fit(1, "test:new", [doc]) is None
doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z"
+6 -1
View File
@@ -38,7 +38,12 @@ def test_persisted_summaries_equal_full_resolution_and_raw_data_is_untouched():
expected = analyse(row)
persist_analysis(row)
assert row.analysis_version == ANALYSIS_VERSION
assert row.analysis_summary == {key: expected[key] for key in ("summary", "terrain", "laps")}
for key in ("terrain", "laps"):
assert row.analysis_summary[key] == expected[key]
for key, value in expected["summary"].items():
assert row.analysis_summary["summary"][key] == value
assert row.analysis_summary["segments"]
assert "heart_rate_drift" in row.analysis_summary["summary"]
assert row.samples == original
assert len(row.display_data["chart"]) == CHART_LIMIT
assert row.display_data["chart"][0]["distance"] == 0