From f951e888bd1cded3840545301943fbe382112094 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Fri, 2 Oct 2026 15:48:28 +1000 Subject: [PATCH] Add guided running stats explorer with validated queries and comparisons --- README.md | 14 ++ app/routes.py | 49 +++++++ app/static/stats.css | 4 + app/static/stats.js | 117 +++++++++++++++ app/stats.py | 306 +++++++++++++++++++++++++++++++++++++++ app/templates/base.html | 1 + app/templates/stats.html | 37 +++++ tests/test_stats.py | 166 +++++++++++++++++++++ 8 files changed, 694 insertions(+) create mode 100644 app/static/stats.css create mode 100644 app/static/stats.js create mode 100644 app/stats.py create mode 100644 app/templates/stats.html create mode 100644 tests/test_stats.py diff --git a/README.md b/README.md index c254931..1da8ae4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/routes.py b/app/routes.py index a553f02..95c963f 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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"]) diff --git a/app/static/stats.css b/app/static/stats.css new file mode 100644 index 0000000..1482d64 --- /dev/null +++ b/app/static/stats.css @@ -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%}} diff --git a/app/static/stats.js b/app/static/stats.js new file mode 100644 index 0000000..4d3ef8e --- /dev/null +++ b/app/static/stats.js @@ -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); + } + } + }); + }); +})(); diff --git a/app/stats.py b/app/stats.py new file mode 100644 index 0000000..7466207 --- /dev/null +++ b/app/stats.py @@ -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), + } diff --git a/app/templates/base.html b/app/templates/base.html index ec4bffd..95b0710 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -13,6 +13,7 @@
sstride. + Stats
{% if request.endpoint != 'web.profile' %}{{ icon('plus') }} Import run{% endif %}
{% else %} diff --git a/app/templates/stats.html b/app/templates/stats.html new file mode 100644 index 0000000..4e7b85c --- /dev/null +++ b/app/templates/stats.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} +{% block title %}Stats studio · Stride{% endblock %} +{% block head %}{% endblock %} +{% block content %} +
+
YOUR RUNNING / STATS STUDIO

Ask more of your runs.

Build a question. Find the pattern. Explore the runs behind it.

+
+
+
YOUR QUESTION
+
Show and
+from runs during the last weeks, grouped by .
+
+ + + + + + +
+

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.

+{% if error %}{% endif %} +
Whole runs · Recorded timer time · {{ current_user.timezone }}
+
+ +{% if results %} +

{{ results.count }} matching {{ 'run' if results.count == 1 else 'runs' }}

{{ results.start }} – {{ results.end }} · {{ '%.1f'|format(results.distance) }} km{% if query.compare %}
Compared with {{ results.previous_start }} – {{ results.previous_end }} · {{ results.prior_count }} runs{% endif %}

+{% if not results.count %}

No runs match this question.

Try a longer period or widen the distance and heart rate filters.

{% else %} +
{% for card in results.cards %}
{{ card.label }}
{{ card.value }}{{ card.unit }}
{% if query.compare %}

Previous: {{ card.previous }} {{ card.unit }}{% if card.delta %} · {{ card.delta }} {{ 'sec/km' if card.key == 'pace' else card.unit }}{% endif %}

{% endif %}

{{ card.coverage }} of {{ results.count }} runs with usable data

How this is calculated

{{ card.definition }}{% if card.key in ('cadence','power','ground_contact','stride_length') %} Coverage counts runs, not sensor recording time.{% endif %}

{% endfor %}
+
TRENDS / {{ query.group|upper }}

Your question over time

Solid: current · Dashed: previous
{% for card in results.cards %}

{{ card.label }} {{ card.unit }}

{% endfor %}

Select a current-period point to see its contributing runs below. Missing data leaves a gap; comparison runs are aligned by elapsed days.

+{% endif %} +
BEHIND THE NUMBERS

Contributing runs

{% for card in results.cards %}{% endfor %}{% for row in results.rows %}{% for value in row['values'] %}{% endfor %}{% else %}{% endfor %}
RUN / LOCAL DATEDISTANCE{{ card.label|upper }} · {{ card.unit }}
{{ row.title }}
{{ row.date }}
{{ row.distance }} km{{ value }}
No matching runs.
Page {{ results.page }} of {{ results.pages }} · up to 25 runs per page{% if results.page > 1 %}Previous{% endif %}{% if results.page < results.pages %}Next{% endif %}
+ +{% endif %} +
+
+{% endblock %} +{% block scripts %}{% endblock %} diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..d61b9c7 --- /dev/null +++ b/tests/test_stats.py @@ -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