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 @@
{% 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.
☆ Save view
+
+
+
+{% 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 Period All results {% for label in results.chart.labels %}{{ label }} {% endfor %} Show all results
RUN / LOCAL DATE DISTANCE {% for card in results.cards %}{{ card.label|upper }} · {{ card.unit }} {% endfor %}{% for row in results.rows %}{{ row.title }} {{ row.date }} {{ row.distance }} km {% for value in row['values'] %}{{ value }} {% endfor %} {% else %}No matching runs. {% endfor %}
+
+{% 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