Add guided running stats explorer with validated queries and comparisons
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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%}}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user