Add guided running stats explorer with validated queries and comparisons

This commit is contained in:
Peter Stockings
2026-10-02 15:48:28 +10:00
parent 794e74166a
commit f951e888bd
8 changed files with 694 additions and 0 deletions
+14
View File
@@ -47,6 +47,20 @@ migrations. SQLite and deployed PostgreSQL migrations have been verified; see
- Routes are not masked or trimmed. Access control, rather than geographic masking,
determines who can see the full route, including start and finish.
### Exploring running stats
Open **Stats** for the guided question builder: select up to two metrics, filter
by distance and run-average heart rate, group by week or month, and compare an
equally long preceding period. Periods use your account timezone and end today
unless you choose an end date. The explorer covers whole runs and uses persisted
summaries without loading recording samples. Metric definitions and the number
of runs with usable data appear alongside each result.
**Show query** exposes the same question as an editable DSL. Applying it updates
the guided controls. Select a chart point or use the evidence period picker to
inspect contributing runs. Save named views in this browser, or bookmark the
results URL; saved views follow relative dates as new runs are imported.
### Bringing in more activities
Use **Settings & imports -> Connect Garmin** to upload an exported session, then
+49
View File
@@ -29,6 +29,7 @@ from .extensions import db, limiter
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, query_text
web = Blueprint("web", __name__)
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
@@ -193,6 +194,54 @@ def dashboard():
)
@web.get("/stats")
@login_required
def stats():
error = None
results = None
try:
query = parse_query(request.args)
page = int(request.args.get("page", "1"))
results = explore(current_user.id, current_user.timezone, query, page)
except (ValueError, OverflowError) as exc:
error = str(exc)
query = dict(DEFAULT)
return render_template(
"stats.html",
query=query,
query_text=query_text(query),
metrics=METRICS,
results=results,
error=error,
submitted_query=request.args.get("query", ""),
), 400 if error else 200
@web.get("/stats/runs")
@login_required
def stats_runs():
try:
query = parse_query(request.args)
bucket = (
datetime.strptime(request.args.get("bucket", ""), "%d %b %Y").replace(tzinfo=UTC).date()
)
results = explore(
current_user.id,
current_user.timezone,
query,
int(request.args.get("page", "1")),
selected_bucket=bucket,
)
except (ValueError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
return jsonify(
rows=results["rows"],
count=results["evidence_count"],
page=results["page"],
pages=results["pages"],
)
@web.route("/friends", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour", methods=["POST"])
+4
View File
@@ -0,0 +1,4 @@
.stats-nav{padding:9px 13px;border-radius:5px;color:var(--muted);font-size:13px}.stats-nav.active,.stats-nav:hover{background:#292f32;color:var(--ink)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.question-panel{padding:26px;background:linear-gradient(120deg,#232a2d,var(--white));border-top:2px solid var(--green)}.studio-row{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.studio-row .eyebrow{margin:0}.studio-sentence{font-size:23px;line-height:2.4;letter-spacing:-.4px;margin:16px 0 22px}.studio-sentence select,.studio-sentence input{background:#343532;color:#ffc1a5;border:1px solid #605043;border-radius:7px;padding:8px 12px;font-size:17px;max-width:100%}.studio-sentence input{width:80px}.studio-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;border-top:1px solid var(--line);padding-top:20px}.studio-filters label{font-size:12px;color:var(--muted)}.studio-filters input,.studio-filters select{display:block;width:100%;margin-top:7px;padding:10px 12px;background:var(--paper);color:var(--ink);border:1px solid #485154;border-radius:7px;color-scheme:dark}.studio-filters label span{font-size:11px}.studio-bottom{margin-top:24px}.studio-presets{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:18px 0 28px}.studio-presets:empty{display:none}.studio-presets a{font-size:12px;border:1px solid #485154;padding:7px 12px;border-radius:20px;background:var(--white)}.studio-presets a:hover{border-color:var(--green)}.studio-presets button{background:none;color:var(--muted);padding:4px}.studio-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:20px 0}.studio-metrics .stat-value{font-size:36px}.studio-metrics .stat-card p{font-size:12px;color:var(--muted)}.studio-metrics details{font-size:12px;margin-top:18px;color:var(--muted)}.studio-metrics summary{cursor:pointer;color:var(--ink)}.studio-metrics details p{margin-top:8px}.result-heading p{font-size:12px;color:var(--muted);margin-top:8px}.studio-charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;padding:14px 26px 20px}.studio-charts:has(.studio-chart:only-child){grid-template-columns:1fr}.studio-chart{min-width:0}.studio-chart h3{font-size:14px}.studio-chart h3 small{color:var(--muted);font-weight:400}.studio-chart>div{height:260px;margin-top:16px}.studio-chart-help{font-size:12px;color:var(--muted);padding:0 26px 24px}.studio-evidence{margin-top:24px}.studio-pagination{display:flex;align-items:center;justify-content:flex-end;gap:12px;padding:18px 24px;flex-wrap:wrap;font-size:12px;color:var(--muted)}.studio-pagination span{margin-right:auto}.studio-empty{padding:30px;margin-top:20px}.studio-empty p{color:var(--muted);margin-top:10px}#query-editor{margin-top:20px}#query-editor label{font-size:12px;color:var(--muted)}#query-input{display:block;resize:vertical;width:100%;padding:16px;border:1px solid #485154;border-radius:8px;margin:8px 0;background:var(--paper);color:var(--ink);font:14px/1.8 Consolas,monospace}.studio-error{margin-top:16px;color:#ffb5a1}#studio-status{margin-top:16px}.studio-chart canvas{cursor:pointer}
.studio-period{font-size:12px;color:var(--muted)}.studio-period select{padding:8px;border:1px solid #485154;border-radius:6px;background:var(--paper);color:var(--ink);margin-left:8px}
@media(max-width:900px){.studio-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.studio-charts{grid-template-columns:1fr}}@media(max-width:680px){.question-panel{padding:18px}.studio-sentence{font-size:18px}.studio-sentence select{font-size:16px;padding:7px}.studio-filters{grid-template-columns:1fr 1fr;gap:12px}.studio-metrics{grid-template-columns:1fr}.stats-nav{padding:8px;font-size:12px}.app-header{gap:6px}.studio-charts{padding:16px}.studio-chart-help{padding:0 16px 18px}.studio-evidence .section-head{flex-wrap:wrap}.studio-row .field-help{max-width:100%}}
+117
View File
@@ -0,0 +1,117 @@
(() => {
'use strict';
const root = document.getElementById('stats-studio');
if (!root) return;
const form = document.getElementById('explorer-form');
const editor = document.getElementById('query-editor');
const input = document.getElementById('query-input');
const toggle = document.getElementById('query-toggle');
const status = document.getElementById('studio-status');
const value = name => form.elements.namedItem(name).value;
function serialize() {
const lines = ['runs', ` during last ${value('weeks')} weeks`];
if (value('end')) lines.push(` ending ${value('end')}`);
lines.push(` where distance >= ${value('minimum') || 0} km`);
[['maximum', 'distance <=', 'km'], ['hr_min', 'heart_rate >=', 'bpm'], ['hr_max', 'heart_rate <=', 'bpm']].forEach(([key, clause, unit]) => {
if (value(key)) lines.push(` and ${clause} ${value(key)} ${unit}`);
});
lines.push(` summarize ${value('metric')}${value('secondary') === 'none' ? '' : ', ' + value('secondary')}`, ` by ${value('group')}`, ` compare ${value('compare') === 'yes' ? 'previous period' : 'none'}`);
return lines.join('\n');
}
form.addEventListener('change', event => { if (event.target !== input) input.value = serialize(); });
toggle.addEventListener('click', () => {
if (!editor.hidden) {
// Apply edited DSL on the server before returning to synchronized guided controls.
if (input.value.trim() !== serialize().trim()) { input.name = 'query'; form.submit(); return; }
}
editor.hidden = !editor.hidden;
toggle.setAttribute('aria-expanded', String(!editor.hidden));
toggle.textContent = editor.hidden ? '〈/〉 Show query' : '〈/〉 Hide query';
});
form.addEventListener('submit', () => { if (!editor.hidden) input.name = 'query'; });
const storageKey = `stride.stats.views.${root.dataset.owner}`;
const views = document.getElementById('saved-views');
let saved = [];
try { const parsed = JSON.parse(localStorage.getItem(storageKey) || '[]'); if (Array.isArray(parsed)) saved = parsed.filter(x => typeof x.name === 'string' && typeof x.query === 'string').slice(0, 12); } catch (_) { /* Storage may be unavailable. */ }
function renderSaved() {
views.replaceChildren();
saved.forEach((view, index) => {
const wrapper = document.createElement('span');
const link = document.createElement('a'); link.textContent = view.name;
link.href = '/stats?' + new URLSearchParams({query: view.query});
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '×'; remove.setAttribute('aria-label', `Remove ${view.name}`);
remove.onclick = () => { saved.splice(index, 1); persist(); renderSaved(); };
wrapper.append(link, remove); views.append(wrapper);
});
}
function persist() { try { localStorage.setItem(storageKey, JSON.stringify(saved)); status.textContent = 'Saved views are stored in this browser.'; } catch (_) { status.textContent = 'Browser storage is unavailable; bookmark this results page to save the question.'; } }
document.getElementById('save-view').onclick = () => {
const name = window.prompt('Name this view', `${value('metric').replaceAll('_', ' ')} · last ${value('weeks')} weeks`);
if (!name || !name.trim()) return;
const query = editor.hidden ? serialize() : input.value;
saved = [{name: name.trim().slice(0, 80), query}, ...saved.filter(v => v.query !== query)].slice(0, 12);
persist(); renderSaved();
};
renderSaved();
const data = document.getElementById('stats-data');
if (!data || typeof Chart === 'undefined') return;
const chartData = JSON.parse(data.textContent);
const pace = n => { const s = Math.round(n); return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`; };
const clear = document.getElementById('clear-selection');
const tbody = document.getElementById('evidence-body');
const original = [...tbody.children].map(row => row.cloneNode(true));
const pagination = root.querySelector('.studio-pagination');
const period = document.getElementById('evidence-period');
const originalPagination = [...pagination.children].map(node => node.cloneNode(true));
let pending = null;
clear.onclick = () => { pending?.abort(); period.value = ''; tbody.replaceChildren(...original.map(row => row.cloneNode(true))); pagination.replaceChildren(...originalPagination.map(node => node.cloneNode(true))); clear.hidden = true; document.getElementById('evidence-heading').textContent = 'Contributing runs'; status.textContent = ''; };
period.onchange = () => { if (period.value) loadBucket(period.value); else clear.click(); };
async function loadBucket(bucket, page = 1) {
pending?.abort();
const controller = new AbortController(); pending = controller;
const url = new URL('/stats/runs', location.origin);
url.search = new URLSearchParams({query:root.dataset.query,bucket,page});
status.textContent = 'Loading contributing runs…';
try {
const response = await fetch(url, {signal:controller.signal});
if (!response.ok) throw new Error();
const result = await response.json();
tbody.replaceChildren();
result.rows.forEach(row => {
const tr = document.createElement('tr'); const td = document.createElement('td');
const a = document.createElement('a'); a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`;
td.append(a); tr.append(td);
[`${row.distance} km`, ...row.values].forEach(text => { const cell = document.createElement('td'); cell.textContent = text; tr.append(cell); });
tbody.append(tr);
});
document.getElementById('evidence-heading').textContent = `${bucket} · ${result.count} runs`;
period.value = bucket;
pagination.replaceChildren();
const label = document.createElement('span'); label.textContent = `Page ${result.page} of ${result.pages} · selected period`; pagination.append(label);
[[result.page > 1, 'Previous', result.page - 1], [result.page < result.pages, 'Next', result.page + 1]].forEach(([show, name, target]) => {
if (!show) return; const button = document.createElement('button'); button.type = 'button'; button.className = 'button secondary'; button.textContent = name; button.onclick = () => loadBucket(bucket, target); pagination.append(button);
});
clear.hidden = false; status.textContent = 'Selected period loaded.';
} catch (error) { if (error.name !== 'AbortError') status.textContent = 'Could not load this period. Try again.'; }
}
chartData.series.forEach((series, index) => {
const canvas = document.getElementById(`stats-chart-${index}`);
if (!canvas) return;
new Chart(canvas, {
type: ['distance', 'duration', 'elevation'].includes(series.key) ? 'bar' : 'line',
data: {labels: chartData.labels, datasets: [
{label:'Current', data:series.values, borderColor:'#f48459', backgroundColor:'#f4845940', borderWidth:2, pointRadius:3, tension:.2, spanGaps:false},
...(series.previous.length ? [{label:'Previous (aligned)', data:series.previous, borderColor:'#9ba4a5', backgroundColor:'#9ba4a526', borderDash:[5,5], borderWidth:2, pointRadius:2, spanGaps:false}] : [])
]},
options: {responsive:true, maintainAspectRatio:false, animation:false,
plugins:{legend:{labels:{color:'#bcc4c5'}}, tooltip:{callbacks:{label:c => `${c.dataset.label}: ${c.parsed.y == null ? '—' : series.key === 'pace' ? pace(c.parsed.y) : c.parsed.y.toFixed(1)} ${series.unit}`}}},
scales:{x:{ticks:{color:'#9ba4a5',maxTicksLimit:5},grid:{display:false}},y:{title:{display:true,text:series.unit,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>series.key==='pace'?pace(n):new Intl.NumberFormat(undefined,{maximumFractionDigits:2}).format(n)},grid:{color:'#303638'}}},
onClick:(_, elements) => {
const point = elements.find(e => e.datasetIndex === 0); if (!point) return;
const bucket = chartData.labels[point.index];
loadBucket(bucket);
}
}
});
});
})();
+306
View File
@@ -0,0 +1,306 @@
"""Validated run explorer queries; never load recording samples."""
import math
import re
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
from .analytics import pace_text
from .extensions import db
from .models import Activity
METRICS = {
"pace": ("Pace", "/km", "Total timer time divided by total distance."),
"heart_rate": ("Heart rate", "bpm", "Run averages weighted by recorded timer duration."),
"distance": ("Distance", "km", "Total recorded distance."),
"duration": ("Timer time", "hours", "Total recorded timer time, excluding pauses."),
"elevation": ("Ascent", "m", "Total recorded elevation gain."),
"cadence": ("Cadence", "spm", "Recorded sample averages weighted by run timer duration."),
"power": ("Power", "W", "Recorded sample averages weighted by run timer duration."),
"ground_contact": (
"Ground contact",
"ms",
"Recorded sample averages weighted by run timer duration.",
),
"stride_length": (
"Stride length",
"m",
"Recorded sample averages weighted by run timer duration.",
),
}
DEFAULT = {
"metric": "pace",
"secondary": "heart_rate",
"weeks": 12,
"group": "week",
"minimum": 0.0,
"maximum": None,
"hr_min": None,
"hr_max": None,
"compare": True,
"end": None,
}
def parse_query(args):
q = dict(DEFAULT)
if "query" in args:
text = args["query"]
if len(text) > 2000:
raise ValueError("Keep the query under 2,000 characters.")
patterns = [
(r"runs", None),
(r"during last (\d+) weeks", "weeks"),
(r"ending (\d{4}-\d{2}-\d{2})", "end"),
(r"where distance >= ([0-9.eE+-]+) km", "minimum"),
(r"and distance <= ([0-9.eE+-]+) km", "maximum"),
(r"and heart_rate >= ([0-9.eE+-]+) bpm", "hr_min"),
(r"and heart_rate <= ([0-9.eE+-]+) bpm", "hr_max"),
(r"summarize (\w+)(?:, (\w+))?", "metrics"),
(r"by (week|month)", "group"),
(r"compare (previous period|none)", "compare"),
]
seen = set()
for line in text.strip().splitlines():
line = line.strip()
if not line:
continue
for pattern, key in patterns:
match = re.fullmatch(pattern, line)
if match:
if key in seen:
raise ValueError(f"Repeated query clause: {line}")
seen.add(key)
if key == "metrics":
q["metric"], q["secondary"] = match.group(1), match.group(2) or "none"
elif key == "compare":
q[key] = match.group(1) == "previous period"
elif key:
q[key] = match.group(1)
break
else:
raise ValueError(f"Unrecognised query clause: {line}")
if None not in seen or "metrics" not in seen:
raise ValueError("Include 'runs' and 'summarize pace, heart_rate' (or other metrics).")
else:
for key in q:
if key in args:
q[key] = args[key] or None
q["compare"] = args.get("compare", "yes") == "yes"
if q["metric"] not in METRICS or q["secondary"] not in {*METRICS, "none"}:
raise ValueError("Choose a supported metric.")
if q["group"] not in {"week", "month"}:
raise ValueError("Group by week or month.")
try:
q["weeks"] = int(q["weeks"])
if not 1 <= q["weeks"] <= 104:
raise ValueError
for key in ("minimum", "maximum", "hr_min", "hr_max"):
q[key] = float(q[key]) if q[key] is not None else None
if q[key] is not None and (not math.isfinite(q[key]) or not 0 <= q[key] <= 1000):
raise ValueError
q["minimum"] = q["minimum"] or 0
q["end"] = date.fromisoformat(q["end"]) if q["end"] else None
except (ValueError, TypeError):
raise ValueError(
"Use 1–104 weeks, a valid end date, and numeric filters from 0–1,000."
) from None
for low, high in (("minimum", "maximum"), ("hr_min", "hr_max")):
if q[low] is not None and q[high] is not None and q[low] > q[high]:
raise ValueError("A filter's minimum cannot exceed its maximum.")
return q
def query_text(q):
lines = ["runs", f" during last {q['weeks']} weeks"]
if q["end"]:
lines.append(f" ending {q['end']}")
lines.append(f" where distance >= {q['minimum']:g} km")
for key, clause, unit in (
("maximum", "distance <=", "km"),
("hr_min", "heart_rate >=", "bpm"),
("hr_max", "heart_rate <=", "bpm"),
):
if q[key] is not None:
lines.append(f" and {clause} {q[key]:g} {unit}")
metrics = q["metric"] + (f", {q['secondary']}" if q["secondary"] != "none" else "")
lines.extend(
[
f" summarize {metrics}",
f" by {q['group']}",
" compare " + ("previous period" if q["compare"] else "none"),
]
)
return "\n".join(lines)
def metric_value(row, key):
if key == "pace":
return row.duration_s * 1000 / row.distance_m if row.distance_m > 0 else None
if key == "heart_rate":
return row.avg_hr
if key in {"distance", "duration", "elevation"}:
return (
getattr(
row,
{"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[
key
],
)
/ {"distance": 1000, "duration": 3600, "elevation": 1}[key]
)
return (row.analysis_summary or {}).get(key)
def aggregate(rows, key):
pairs = [(r, metric_value(r, key)) for r in rows]
pairs = [
(r, v)
for r, v in pairs
if isinstance(v, (float, int))
and math.isfinite(v)
and (v > 0 or key in {"distance", "duration", "elevation"})
and (key in {"distance", "duration", "elevation", "pace"} or r.duration_s > 0)
]
if not pairs:
return None, 0
if key in {"distance", "duration", "elevation"}:
value = sum(v for _, v in pairs)
else:
weights = [(v, r.distance_m if key == "pace" else r.duration_s) for r, v in pairs]
denominator = sum(w for _, w in weights)
value = sum(v * w for v, w in weights) / denominator if denominator > 0 else None
return value, len(pairs)
def format_value(value, key):
if value is None:
return "—"
return (
pace_text(value)
if key == "pace"
else f"{value:,.2f}"
if key == "stride_length"
else f"{value:,.1f}"
)
def explore(owner_id, timezone, q, page=1, selected_bucket=None):
zone = ZoneInfo(timezone)
end_day = q["end"] or datetime.now(UTC).astimezone(zone).date()
end = end_day + timedelta(days=1)
start = end - timedelta(weeks=q["weeks"])
previous = start - timedelta(weeks=q["weeks"])
boundary = lambda day: datetime.combine(day, datetime.min.time(), zone).astimezone(UTC)
filters = [
Activity.owner_id == owner_id,
Activity.sport == "running",
Activity.started_at >= boundary(previous if q["compare"] else start),
Activity.started_at < boundary(end),
Activity.distance_m >= q["minimum"] * 1000,
]
for key, column, multiplier, operator in (
("maximum", Activity.distance_m, 1000, "le"),
("hr_min", Activity.avg_hr, 1, "ge"),
("hr_max", Activity.avg_hr, 1, "le"),
):
if q[key] is not None:
filters.append(column <= q[key] * multiplier if operator == "le" else column >= q[key])
rows = db.session.execute(
db.select(
Activity.id,
Activity.title,
Activity.started_at,
Activity.distance_m,
Activity.duration_s,
Activity.elevation_m,
Activity.avg_hr,
Activity.analysis_summary,
)
.where(*filters)
.order_by(Activity.started_at.desc(), Activity.id.desc())
).all()
localday = lambda r: (
(r.started_at.replace(tzinfo=UTC) if r.started_at.tzinfo is None else r.started_at)
.astimezone(zone)
.date()
)
current = [r for r in rows if localday(r) >= start]
prior = [r for r in rows if localday(r) < start]
keys = list(
dict.fromkeys([q["metric"], *([q["secondary"]] if q["secondary"] != "none" else [])])
)
cards = []
for key in keys:
value, coverage = aggregate(current, key)
before, _ = aggregate(prior, key)
cards.append(
{
"key": key,
"label": METRICS[key][0],
"unit": METRICS[key][1],
"definition": METRICS[key][2],
"value": format_value(value, key),
"coverage": coverage,
"previous": format_value(before, key),
"delta": f"{value - before:+.1f}"
if value is not None and before is not None
else None,
}
)
bucket = lambda day: (
day.replace(day=1) if q["group"] == "month" else day - timedelta(days=day.weekday())
)
dates = sorted({bucket(start + timedelta(days=n)) for n in range((end - start).days)})
series = []
for key in keys:
values, prior_values = [], []
for day in dates:
values.append(aggregate([r for r in current if bucket(localday(r)) == day], key)[0])
# Align prior runs by elapsed days into the equally long comparison period.
prior_values.append(
aggregate(
[r for r in prior if bucket(localday(r) + (start - previous)) == day], key
)[0]
)
series.append(
{
"key": key,
"label": METRICS[key][0],
"unit": METRICS[key][1],
"values": values,
"previous": prior_values if q["compare"] else [],
}
)
evidence = current
if selected_bucket is not None:
if selected_bucket not in dates:
raise ValueError("Choose a period from the current chart.")
evidence = [r for r in current if bucket(localday(r)) == selected_bucket]
pages = max(1, math.ceil(len(evidence) / 25))
if not 1 <= page <= pages:
raise ValueError("That results page does not exist.")
return {
"cards": cards,
"chart": {"labels": [d.strftime("%d %b %Y") for d in dates], "series": series},
"count": len(current),
"prior_count": len(prior),
"distance": sum(r.distance_m for r in current) / 1000,
"rows": [
{
"id": r.id,
"title": r.title,
"date": localday(r).strftime("%d %b %Y"),
"distance": f"{r.distance_m / 1000:.2f}",
"values": [format_value(metric_value(r, k), k) for k in keys],
}
for r in evidence[(page - 1) * 25 : page * 25]
],
"start": start,
"end": end_day,
"previous_start": previous,
"previous_end": start - timedelta(days=1),
"page": page,
"pages": pages,
"evidence_count": len(evidence),
}
+1
View File
@@ -13,6 +13,7 @@
<header class="app-header">
<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>
<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>
{% else %}
+37
View File
@@ -0,0 +1,37 @@
{% extends 'base.html' %}
{% block title %}Stats studio · Stride{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='stats.css') }}">{% endblock %}
{% block content %}
<div id="stats-studio" data-owner="{{ current_user.id }}" data-query="{{ query_text }}">
<div class="page-heading"><div><span class="eyebrow">YOUR RUNNING / STATS STUDIO</span><h1>Ask more of your runs.</h1><p>Build a question. Find the pattern. Explore the runs behind it.</p></div><button type="button" class="button secondary" id="save-view">☆ Save view</button></div>
<div id="saved-views" class="studio-presets" aria-label="Saved views"></div>
<form method="get" id="explorer-form" class="panel question-panel">
<div class="studio-row"><span class="eyebrow">YOUR QUESTION</span><button type="button" class="text-button" id="query-toggle" aria-expanded="{{ 'true' if error else 'false' }}" aria-controls="query-editor">〈/〉 {{ 'Hide' if error else 'Show' }} query</button></div>
<div class="studio-sentence">Show <label class="sr-only" for="metric">Primary metric</label><select id="metric" name="metric">{% for key, info in metrics.items() %}<option value="{{ key }}" {{ 'selected' if query.metric == key }}>{{ info[0]|lower }}</option>{% endfor %}</select> and <label class="sr-only" for="secondary">Second metric</label><select id="secondary" name="secondary"><option value="none" {{ 'selected' if query.secondary == 'none' }}>no second metric</option>{% for key, info in metrics.items() %}<option value="{{ key }}" {{ 'selected' if query.secondary == key }}>{{ info[0]|lower }}</option>{% endfor %}</select><br>
from runs during the last <label class="sr-only" for="weeks">Number of weeks</label><input id="weeks" name="weeks" type="number" min="1" max="104" value="{{ query.weeks }}" required> weeks, grouped by <label class="sr-only" for="group">Grouping</label><select id="group" name="group"><option value="week" {{ 'selected' if query.group == 'week' }}>week</option><option value="month" {{ 'selected' if query.group == 'month' }}>month</option></select>.</div>
<div class="studio-filters">
<label>Distance at least · km<input name="minimum" type="number" min="0" max="1000" step="any" value="{{ query.minimum }}"></label>
<label>Distance at most · km<input name="maximum" type="number" min="0" max="1000" step="any" value="{{ query.maximum if query.maximum is not none else '' }}" placeholder="Any"></label>
<label>Heart rate at least · bpm<input name="hr_min" type="number" min="0" max="1000" step="any" value="{{ query.hr_min if query.hr_min is not none else '' }}" placeholder="Any"></label>
<label>Heart rate at most · bpm<input name="hr_max" type="number" min="0" max="1000" step="any" value="{{ query.hr_max if query.hr_max is not none else '' }}" placeholder="Any"></label>
<label>Ending on<input name="end" type="date" value="{{ query.end or '' }}" aria-describedby="end-help"><span id="end-help">Blank = today</span></label>
<label>Compare with<select name="compare"><option value="yes" {{ 'selected' if query.compare }}>Previous period</option><option value="no" {{ 'selected' if not query.compare }}>No comparison</option></select></label>
</div>
<div id="query-editor" {% if not error %}hidden{% endif %}><label for="query-input">Edit query</label><textarea id="query-input" spellcheck="false" rows="8">{{ submitted_query if error and submitted_query else query_text }}</textarea><p class="field-help">Supported clauses: runs, during last N weeks, ending YYYY-MM-DD, distance and heart_rate filters, summarize, by week/month, compare previous period/none. Filters use km and bpm.</p></div>
{% if error %}<p class="studio-error" role="alert">{{ error }}</p>{% endif %}
<div class="studio-row studio-bottom"><span class="field-help">Whole runs · Recorded timer time · {{ current_user.timezone }}</span><button class="button primary" type="submit">Explore {{ icon('arrow') }}</button></div>
</form>
<div class="studio-presets" aria-label="Starting questions"><span class="subtle-label">START WITH</span><a href="{{ url_for('web.stats',metric='distance',secondary='duration') }}">Weekly volume</a><a href="{{ url_for('web.stats',metric='pace',secondary='heart_rate',minimum=10) }}">Long-run progression</a><a href="{{ url_for('web.stats',metric='pace',secondary='heart_rate',hr_min=140,hr_max=150) }}">Pace at similar heart rate</a><a href="{{ url_for('web.stats',metric='cadence',secondary='ground_contact') }}">Running form</a></div>
{% if results %}
<div class="studio-row result-heading"><div><h2>{{ results.count }} matching {{ 'run' if results.count == 1 else 'runs' }}</h2><p>{{ results.start }} – {{ results.end }} · {{ '%.1f'|format(results.distance) }} km{% if query.compare %}<br>Compared with {{ results.previous_start }} – {{ results.previous_end }} · {{ results.prior_count }} runs{% endif %}</p></div></div>
{% if not results.count %}<div class="panel studio-empty"><h2>No runs match this question.</h2><p>Try a longer period or widen the distance and heart rate filters.</p></div>{% else %}
<div class="studio-metrics">{% for card in results.cards %}<article class="stat-card"><span class="eyebrow">{{ card.label }}</span><div class="stat-value">{{ card.value }}<small>{{ card.unit }}</small></div>{% if query.compare %}<p>Previous: {{ card.previous }} {{ card.unit }}{% if card.delta %} · {{ card.delta }} {{ 'sec/km' if card.key == 'pace' else card.unit }}{% endif %}</p>{% endif %}<p>{{ card.coverage }} of {{ results.count }} runs with usable data</p><details><summary>How this is calculated</summary><p>{{ card.definition }}{% if card.key in ('cadence','power','ground_contact','stride_length') %} Coverage counts runs, not sensor recording time.{% endif %}</p></details></article>{% endfor %}</div>
<section class="panel"><div class="section-head"><div><span class="eyebrow">TRENDS / {{ query.group|upper }}</span><h2>Your question over time</h2></div><span class="subtle-label">Solid: current · Dashed: previous</span></div><div class="studio-charts">{% for card in results.cards %}<div class="studio-chart"><h3>{{ card.label }} <small>{{ card.unit }}</small></h3><div><canvas id="stats-chart-{{ loop.index0 }}" role="img" aria-label="{{ card.label }} grouped by {{ query.group }}"></canvas></div></div>{% endfor %}</div><p class="studio-chart-help">Select a current-period point to see its contributing runs below. Missing data leaves a gap; comparison runs are aligned by elapsed days.</p></section>
{% endif %}
<section class="panel studio-evidence"><div class="section-head"><div><span class="eyebrow">BEHIND THE NUMBERS</span><h2 id="evidence-heading">Contributing runs</h2></div><div class="studio-row"><label class="studio-period" for="evidence-period">Period <select id="evidence-period"><option value="">All results</option>{% for label in results.chart.labels %}<option>{{ label }}</option>{% endfor %}</select></label><button type="button" class="text-button" id="clear-selection" hidden>Show all results</button></div></div><div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>RUN / LOCAL DATE</th><th>DISTANCE</th>{% for card in results.cards %}<th>{{ card.label|upper }} · {{ card.unit }}</th>{% endfor %}</tr></thead><tbody id="evidence-body">{% for row in results.rows %}<tr><td><a href="{{ url_for('web.activity',activity_id=row.id) }}"><strong>{{ row.title }}</strong><br><small>{{ row.date }}</small></a></td><td>{{ row.distance }} km</td>{% for value in row['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% else %}<tr><td colspan="4">No matching runs.</td></tr>{% endfor %}</tbody></table></div><div class="studio-pagination"><span>Page {{ results.page }} of {{ results.pages }} · up to 25 runs per page</span>{% if results.page > 1 %}<a class="button secondary" href="{{ url_for('web.stats',query=query_text,page=results.page-1) }}">Previous</a>{% endif %}{% if results.page < results.pages %}<a class="button secondary" href="{{ url_for('web.stats',query=query_text,page=results.page+1) }}">Next</a>{% endif %}</div></section>
<script id="stats-data" type="application/json">{{ results.chart|tojson }}</script>
{% endif %}
<div id="studio-status" role="status" class="field-help"></div>
</div>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='stats.js') }}"></script>{% endblock %}
+166
View File
@@ -0,0 +1,166 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import event
from test_app import add_history, login
from app.extensions import db
from app.models import Activity
from app.stats import explore, parse_query, query_text
pytest_plugins = ("test_app",)
def test_query_round_trip_and_validation():
query = parse_query(
{
"metric": "cadence",
"secondary": "none",
"weeks": "26",
"minimum": "8",
"maximum": "20",
"hr_min": "140",
"hr_max": "150",
"end": "2026-10-02",
"compare": "no",
}
)
assert parse_query({"query": query_text(query)}) == query
tiny = parse_query({"minimum": "0.0000001"})
assert parse_query({"query": query_text(tiny)}) == tiny
for args in (
{"weeks": "105"},
{"query": ""},
{"minimum": "nan"},
{"minimum": "12", "maximum": "4"},
{"metric": "password"},
{"end": "2026-02-30"},
{"query": "runs\nwhere owner_id = 2"},
{"query": "runs\nsummarize pace\nby week\nby month"},
):
with pytest.raises(ValueError):
parse_query(args)
def test_weighting_missing_data_and_private_scope(app):
with app.app_context():
db.session.add_all(
[
Activity(
owner_id=1,
source_key="stats:long",
title="Long",
sport="running",
started_at=datetime(2026, 9, 29, tzinfo=UTC),
distance_m=15000,
duration_s=4500,
avg_hr=None,
),
Activity(
owner_id=2,
source_key="stats:private",
title="Secret",
sport="running",
started_at=datetime(2026, 9, 29, tzinfo=UTC),
distance_m=100000,
duration_s=10000,
avg_hr=200,
),
Activity(
owner_id=1,
source_key="stats:bike",
title="Bike",
sport="cycling",
started_at=datetime(2026, 9, 29, tzinfo=UTC),
distance_m=100000,
duration_s=10000,
),
]
)
db.session.commit()
statements = []
def record(_conn, _cursor, statement, _parameters, _context, _many):
statements.append(statement)
event.listen(db.engine, "before_cursor_execute", record)
try:
result = explore(1, "Australia/Sydney", parse_query({"end": "2026-10-02"}))
finally:
event.remove(db.engine, "before_cursor_execute", record)
assert result["count"] == 2
assert result["cards"][0]["value"] == "5:15"
assert result["cards"][1]["value"] == "140.0"
assert result["cards"][1]["coverage"] == 1
assert all("samples" not in sql and "route_geometry" not in sql for sql in statements)
def test_stats_routes_and_drillthrough(app):
client = app.test_client()
assert client.get("/stats").status_code == 302
assert client.get("/stats/runs").status_code == 302
login(client)
response = client.get("/stats?end=2026-10-02")
assert response.status_code == 200
assert b"A quiet morning run" in response.data
assert b"5:00" not in response.data # recorded pace is 6:00/km
response = client.get("/stats/runs?end=2026-10-02&bucket=28+Sep+2026")
assert response.status_code == 200
assert response.json["count"] == 1
assert response.json["rows"][0]["values"] == ["6:00", "140.0"]
assert client.get("/stats/runs?bucket=bad").status_code == 400
assert client.get("/stats?metric=unknown").status_code == 400
assert b"No runs match" in client.get("/stats?minimum=1000").data
client.post("/logout")
login(client, "other")
assert b"A quiet morning run" not in client.get("/stats?end=2026-10-02").data
assert client.get("/stats/runs?end=2026-10-02&bucket=28+Sep+2026").json["rows"] == []
def test_local_date_boundary_and_previous_alignment(app):
with app.app_context():
db.session.add(
Activity(
owner_id=1,
source_key="stats:boundary",
title="Local midnight",
sport="running",
started_at=datetime(2026, 9, 24, 14, tzinfo=UTC),
distance_m=1000,
duration_s=300,
)
)
db.session.add(
Activity(
owner_id=1,
source_key="stats:prior",
title="Prior",
sport="running",
started_at=datetime(2026, 9, 20, tzinfo=UTC),
distance_m=2000,
duration_s=800,
)
)
db.session.commit()
result = explore(1, "Australia/Sydney", parse_query({"weeks": "1", "end": "2026-10-01"}))
assert result["start"].isoformat() == "2026-09-25"
assert result["count"] == 2
assert result["prior_count"] == 1
assert 400 in result["chart"]["series"][0]["previous"]
def test_evidence_pagination(app):
with app.app_context():
add_history(40)
db.session.commit()
client = app.test_client()
login(client)
args = {"end": "2026-10-15", "group": "month", "bucket": "01 Sep 2026"}
response = client.get("/stats/runs", query_string=args)
assert response.status_code == 200
assert response.json["count"] == 31
assert len(response.json["rows"]) == 25
second = client.get("/stats/runs", query_string={**args, "page": 2})
assert len(second.json["rows"]) == 6
assert not {r["id"] for r in response.json["rows"]} & {r["id"] for r in second.json["rows"]}
assert client.get("/stats/runs", query_string={**args, "page": 3}).status_code == 400