Extend run stats DSL with statistical and temporal analysis

This commit is contained in:
Peter Stockings
2026-10-02 17:37:01 +10:00
parent 987f470ea5
commit a06189849e
12 changed files with 1807 additions and 214 deletions
+77 -4
View File
@@ -1,4 +1,6 @@
import csv
import hashlib
import io
import re
import secrets
from datetime import UTC, datetime
@@ -7,6 +9,7 @@ from zoneinfo import available_timezones
from flask import (
Blueprint,
Response,
abort,
current_app,
flash,
@@ -30,6 +33,8 @@ from .history import activity_page, latest_mapped_run, overview, records_for
from .models import Activity, Friendship, GarminConnection, User
from .services import activity_data, import_cache, import_upload, route_data
from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text
from .stats_analysis import EXTRA_UNITS, FUNCTIONS, GROUPS
from .stats_dsl import UNITS
web = Blueprint("web", __name__)
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
@@ -214,6 +219,19 @@ def stats():
results=results,
error=error,
submitted_query=request.args.get("query", ""),
language={
"functions": sorted(FUNCTIONS),
"groups": sorted(GROUPS),
"fields": {
key: {
"label": info[0],
"unit": info[1],
"description": info[2],
"units": list(({**UNITS, **EXTRA_UNITS}).get(key, {})),
}
for key, info in METRICS.items()
},
},
), 400 if error else 200
@@ -222,15 +240,14 @@ def stats():
def stats_runs():
try:
query = parse_query(request.args)
bucket = (
datetime.strptime(request.args.get("bucket", ""), "%d %b %Y").replace(tzinfo=UTC).date()
)
bucket = request.args.get("bucket", "")
results = explore(
current_user.id,
current_user.timezone,
query,
int(request.args.get("page", "1")),
selected_bucket=bucket,
raw_group=request.args.get("scope") == "group",
)
except (ValueError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
@@ -252,13 +269,69 @@ def stats_validate():
try:
query = parse_query({"query": payload["query"]})
period_bounds(query, current_user.timezone)
return jsonify(valid=True, canonical=query_text(query), diagnostics=[])
return jsonify(
valid=True, canonical=query_text(query), diagnostics=[], derived=query["derived"]
)
except (ValueError, OverflowError) as exc:
return jsonify(
valid=False, diagnostics=[{"line": getattr(exc, "line", 1), "message": str(exc)}]
)
@web.get("/stats/export")
@login_required
@limiter.limit("20 per minute")
def stats_export():
try:
query = parse_query(request.args)
result = explore(current_user.id, current_user.timezone, query, export=True)
kind = request.args.get("kind", "groups")
if kind not in {"runs", "groups"}:
raise ValueError("Export runs or groups.")
except (ValueError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
output = io.StringIO(newline="")
writer = csv.writer(output)
def safe(value):
# Keep user-authored run titles inert when opened in a spreadsheet.
text = str(value)
return "'" + text if text.lstrip().startswith(("=", "+", "-", "@")) else text
columns = result["cards"] if kind == "groups" else result["evidence_columns"]
headers = [
f"{card['key']} ({'sec/km' if card['unit'] == '/km' else card['unit']})" for card in columns
]
if kind == "groups":
writer.writerow(["Group", "Contributing runs", *headers])
for group in result["groups"]:
writer.writerow([safe(group["label"]), group["count"], *group["raw_values"]])
else:
writer.writerow(
["Run", "Local date", "Distance (km)", "Previous run", "Previous run date", *headers]
)
for row in result["rows"]:
previous = row["previous_run"] or {}
writer.writerow(
[
safe(row["title"]),
row["date"],
row["distance"],
safe(previous.get("title", "")),
previous.get("date", ""),
*row["raw_values"],
]
)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={
"Content-Disposition": f'attachment; filename="stride-{kind}.csv"',
"Cache-Control": "private, no-store",
},
)
@web.route("/friends", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour", methods=["POST"])