Add saved analysis workspace and live dashboard statistics

This commit is contained in:
Peter Stockings
2026-10-03 12:27:20 +10:00
parent 5ede1a01d2
commit e6e2f22479
31 changed files with 1986 additions and 131 deletions
+3
View File
@@ -69,6 +69,9 @@ def create_app(config=None):
from .routes import web
app.register_blueprint(web)
from .analysis_library import library
app.register_blueprint(library)
from .planner import planner
app.register_blueprint(planner)
+113 -13
View File
@@ -71,7 +71,7 @@ def formatted(number, key, q):
return "—"
spec = q["aggregates"].get(key)
raw = spec["field"] if spec and spec["function"] not in {"count", "stddev", "sum"} else key
if raw == "pace":
if raw in {"pace", "gap"}:
return format_value(number, "pace")
if key == "count" or (spec and spec["function"] == "count"):
return f"{number:,.0f}"
@@ -130,9 +130,12 @@ def ordered(rows, ordering):
def prepare(raw, zone, q, catalog):
rows, windows, totals = [], {7: deque(), 28: deque()}, {7: 0.0, 28: 0.0}
previous, last_day, streak = None, None, 0
trees = [
(spec["name"], expression_tree(spec["expression"], {**catalog})[0]) for spec in q["derived"]
]
trees = {
spec["name"]: expression_tree(spec["expression"], catalog)[0]
for spec in q["derived"]
if "window" not in spec
}
custom_windows = {spec["name"]: deque() for spec in q["derived"] if "window" in spec}
for record in raw:
row = SimpleNamespace(**record._mapping)
row.instant = (
@@ -150,6 +153,7 @@ def prepare(raw, zone, q, catalog):
if row.distance_m > 0
else None,
max_heart_rate=(row.analysis_summary or {}).get("max_hr")
or (row.analysis_summary or {}).get("summary", {}).get("max_hr")
or (row.metrics or {}).get("max_heart_rate"),
calories=(row.metrics or {}).get("total_calories"),
training_effect=(row.metrics or {}).get("total_training_effect"),
@@ -189,8 +193,17 @@ def prepare(raw, zone, q, catalog):
streak = streak + 1 if last_day and (row.day - last_day).days == 1 else 1
last_day = row.day
row.values["run_day_streak"] = streak
for name, tree in trees:
row.values[name] = evaluate(tree, row.values)
for spec in q["derived"]:
name = spec["name"]
if "window" not in spec:
row.values[name] = evaluate(trees[name], row.values)
continue
window = custom_windows[name]
config = spec["window"]
while window and (row.instant - window[0][0]).total_seconds() >= config["days"] * 86400:
window.popleft()
window.append((row.instant, row.values[config["field"]]))
row.values[name] = statistic([item[1] for item in window], config["function"])
rows.append(row)
previous = row
return rows
@@ -199,9 +212,19 @@ def prepare(raw, zone, q, catalog):
def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, raw_group=False):
zone, catalog = ZoneInfo(timezone), catalog_for(q)
start, end, prior_start = period_bounds(q, timezone)
prior_end = start
if q["compare"] and q["comparison"] == "year":
import calendar
def previous_year(day):
return day.replace(
year=day.year - 1, day=min(day.day, calendar.monthrange(day.year - 1, day.month)[1])
)
prior_start, prior_end = previous_year(start), previous_year(end)
boundary = lambda day: datetime.combine(day, datetime.min.time(), zone).astimezone(UTC)
# Temporal columns use every earlier run, independently of query filters.
raw = db.session.execute(
statement = (
db.select(
Activity.id,
Activity.title,
@@ -219,8 +242,20 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
Activity.started_at < boundary(end),
)
.order_by(Activity.started_at, Activity.id)
).all()
)
if q["source"] != "runs" and not q["all_time"]:
lower = min(
start,
prior_start if q["compare"] else start,
start - timedelta(days=q["baseline"] or 0),
)
statement = statement.where(Activity.started_at >= boundary(lower))
raw = db.session.execute(statement).all()
history = prepare(raw, zone, q, catalog)
if q["source"] != "runs":
from .stats_sources import expand
history = expand(history, q, catalog)
if q["all_time"] and history:
start = history[0].day
prior_start = start
@@ -242,7 +277,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
[
row
for row in history
if q["compare"] and prior_start <= row.day < start and included(row)
if q["compare"] and prior_start <= row.day < prior_end and included(row)
],
q["sort"],
)
@@ -288,7 +323,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return f"{token:02d}:00"
if group == "run":
row = next(row for row in current if row.id == token)
return row.local.strftime("%d %b %Y %H:%M") + f" · #{row.id}"
return row.local.strftime("%d %b %Y %H:%M") + (
f" · {row.title}" if q["source"] != "runs" else f" · #{row.id}"
)
if group == "bin":
unit = catalog[width["field"]][1]
return f"{token * width['width']:g}–{(token + 1) * width['width']:g} {unit}"
@@ -372,11 +409,16 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
kept_prior = {row.id for token in tokens for row in prior_groups[token]}
prior = [row for row in prior if row.id in kept_prior]
descriptors = {key: describe(key, catalog, q["aggregates"]) for key in keys}
for key, title in q["labels"].items():
if key in descriptors:
descriptors[key] = (title, *descriptors[key][1:])
cards = []
for key in keys:
number, coverage = summarize(current, key, q)
before, _ = summarize(prior, key, q)
title, unit, definition = descriptors[key]
if unit == "runs":
unit = q["source"]
cards.append(
{
"key": key,
@@ -438,6 +480,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return results
title, unit, _ = descriptors[key]
if unit == "runs":
unit = q["source"]
pace_axis = unit == "/km"
if q["trend"] == "percent_change":
unit, pace_axis = "%", False
@@ -459,7 +503,57 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"type": q["chart"],
"group": group,
"trend": q["trend"],
"display": q["display"],
"reference": q["reference"],
"dates": [token.isoformat() for token in tokens] if group == "day" else [],
"source": q["source"],
}
if q["cohorts"]:
cohorts = [{"label": cohort["name"], "values": []} for cohort in q["cohorts"]]
unassigned = {"label": "Other / missing", "values": []}
for token in tokens:
partitions = [[] for _ in range(len(cohorts) + 1)]
for row in current_groups[token]:
selected = next(
(
i
for i, cohort in enumerate(q["cohorts"])
if matches(row, cohort["conditions"])
),
len(cohorts),
)
partitions[selected].append(row)
for partition, cohort in zip(partitions, [*cohorts, unassigned], strict=True):
cohort["values"].append(measure(partition, keys[0]))
chart["cohorts"] = [*cohorts, unassigned]
from .stats_insights import enrich
insights = enrich(
chart, q, keys, tokens, current_groups, history, included, start, measure, catalog
)
chart["drill_labels"] = []
for key, series in zip(keys, chart["series"], strict=True):
histogram = series.get("histogram")
if not histogram or not histogram["counts"]:
continue
field = q["aggregates"].get(key, {}).get("field") or (
"count" if key in q["aggregates"] else key
)
edges = histogram["edges"]
bins = [[] for _ in histogram["counts"]]
for row in current:
number = value(row, field)
if valid(number):
index = min(len(bins) - 1, int((number - edges[0]) / histogram["width"]))
bins[index].append(row)
histogram["buckets"] = []
for index in range(len(histogram["counts"])):
name = f"Distribution · {key} · {index + 1}: {edges[index]:g}–{edges[index + 1]:g} {catalog[field][1]}"
rows = bins[index]
evidence_groups[name] = rows
raw_evidence_groups[name] = rows
histogram["buckets"].append(name)
chart["drill_labels"].append(name)
correlation = None
if q["plot"]:
x, y = q["plot"]
@@ -467,8 +561,10 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
{
"x": value(row, x),
"y": value(row, y),
"id": row.id,
"id": getattr(row, "activity_id", row.id),
"label": row.title + " · " + row.local.strftime("%d %b %Y"),
"color": value(row, q["color"]) if q["color"] else None,
"size": value(row, q["size"]) if q["size"] else None,
}
for row in rows
if valid(value(row, x)) and valid(value(row, y))
@@ -486,6 +582,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"current": plotted,
"previous": points(prior) if q["compare"] else [],
"correlation": correlation,
"color": catalog[q["color"]] if q["color"] else None,
"size": catalog[q["size"]] if q["size"] else None,
}
evidence = current
if selected_bucket is not None:
@@ -509,7 +607,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
for key in keys
]
return {
"id": row.id,
"id": getattr(row, "activity_id", row.id),
"title": row.title,
"date": row.local.strftime("%d %b %Y %H:%M"),
"distance": f"{row.distance_m / 1000:.2f}",
@@ -526,7 +624,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return {
"cards": cards,
"source": q["source"],
"chart": chart,
"insights": insights,
"groups": groups,
"evidence_columns": [
{"key": field, "label": catalog[field][0], "unit": catalog[field][1]}
@@ -546,7 +646,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"start": start,
"end": end - timedelta(days=1),
"previous_start": prior_start,
"previous_end": start - timedelta(days=1),
"previous_end": prior_end - timedelta(days=1),
"page": page,
"pages": pages,
"evidence_count": len(evidence),
+165
View File
@@ -0,0 +1,165 @@
"""Private account library. Queries execute afresh against owner-scoped history."""
from flask import Blueprint, abort, jsonify, request
from flask_login import current_user, login_required
from .extensions import db, limiter
from .models import SavedAnalysis
from .stats import explore, parse_query, period_bounds, query_text
library = Blueprint("library", __name__, url_prefix="/stats/analyses")
def serialize(row):
return {
key: getattr(row, key)
for key in (
"id",
"name",
"collection",
"notes",
"query",
"pinned",
"position",
"width",
"view",
)
} | {"updated_at": row.updated_at.isoformat()}
def owned(analysis_id):
row = db.session.scalar(
db.select(SavedAnalysis).filter_by(id=analysis_id, owner_id=current_user.id)
)
if row is None:
abort(404)
return row
def validate(payload, row=None):
if not isinstance(payload, dict):
raise TypeError("Supply analysis settings.")
values = {}
for key, limit in (("name", 100), ("collection", 80), ("notes", 4000)):
value = payload.get(
key,
getattr(row, key)
if row
else {"name": "", "collection": "My analyses", "notes": ""}[key],
)
if (
not isinstance(value, str)
or len(value.strip()) > limit
or (key != "notes" and not value.strip())
):
raise ValueError(f"Supply {key} of up to {limit} characters.")
values[key] = value.strip()
source = payload.get("query", row.query if row else None)
query = parse_query({"query": source})
period_bounds(query, current_user.timezone)
values["query"] = query_text(query)
for key, choices, default in (
("width", {"half", "full"}, "half"),
("view", {"chart", "value", "table"}, "chart"),
):
value = payload.get(key, getattr(row, key) if row else default)
if not isinstance(value, str) or value not in choices:
raise ValueError(f"Choose a supported {key}.")
values[key] = value
pinned = payload.get("pinned", row.pinned if row else False)
if not isinstance(pinned, bool):
raise TypeError("Pinned must be true or false.")
values["pinned"] = pinned
return values
@library.get("")
@login_required
def listing():
rows = db.session.scalars(
db.select(SavedAnalysis)
.filter_by(owner_id=current_user.id)
.order_by(SavedAnalysis.position, SavedAnalysis.id)
).all()
return jsonify(analyses=[serialize(row) for row in rows])
@library.post("")
@login_required
@limiter.limit("60 per minute")
def create():
try:
values = validate(request.get_json(silent=True))
except (ValueError, TypeError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
count = db.session.scalar(
db.select(db.func.count(SavedAnalysis.id)).filter_by(owner_id=current_user.id)
)
if count >= 100:
return jsonify(error="Keep up to 100 saved analyses; delete an unused analysis first."), 400
row = SavedAnalysis(owner_id=current_user.id, position=count, **values)
db.session.add(row)
db.session.commit()
return jsonify(analysis=serialize(row)), 201
@library.patch("/<int:analysis_id>")
@login_required
def update(analysis_id):
row = owned(analysis_id)
try:
values = validate(request.get_json(silent=True), row)
except (ValueError, TypeError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
for key, value in values.items():
setattr(row, key, value)
db.session.commit()
return jsonify(analysis=serialize(row))
@library.delete("/<int:analysis_id>")
@login_required
def delete(analysis_id):
db.session.delete(owned(analysis_id))
db.session.commit()
return "", 204
@library.post("/order")
@login_required
def order():
payload = request.get_json(silent=True)
ids = payload.get("ids") if isinstance(payload, dict) else None
rows = db.session.scalars(
db.select(SavedAnalysis).filter_by(owner_id=current_user.id, pinned=True)
).all()
if (
not isinstance(ids, list)
or any(type(item) is not int for item in ids)
or len(ids) != len(set(ids))
or set(ids) != {row.id for row in rows}
):
return jsonify(error="Supply every pinned analysis exactly once."), 400
positions = {analysis_id: position for position, analysis_id in enumerate(ids)}
for row in rows:
row.position = positions[row.id]
db.session.commit()
return jsonify(ok=True)
@library.get("/<int:analysis_id>/results")
@login_required
@limiter.limit("120 per minute")
def results(analysis_id):
row = owned(analysis_id)
try:
result = explore(current_user.id, current_user.timezone, parse_query({"query": row.query}))
except (ValueError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
for key in ("start", "end", "previous_start", "previous_end"):
result[key] = result[key].isoformat()
return (
jsonify(analysis=serialize(row), results=result),
200,
{"Cache-Control": "private, no-store"},
)
+8 -1
View File
@@ -7,7 +7,7 @@ import click
from .analytics import analyse, numeric
ANALYSIS_VERSION = 1
ANALYSIS_VERSION = 2
CHART_LIMIT = 750
ROUTE_TARGET = 1400
CHART_KEYS = (
@@ -104,8 +104,15 @@ def compact_routes(samples):
def persist_analysis(activity):
from .effort_segments import analyze
analysis = analyse(activity)
effort, segments = analyze(activity.samples or [])
analysis["summary"].update(
{key: effort[key] for key in ("pace_variability", "heart_rate_drift", "sensor_coverage")}
)
activity.analysis_summary = {key: analysis[key] for key in ("summary", "terrain", "laps")}
activity.analysis_summary["segments"] = segments
routes = compact_routes(analysis["samples"])
activity.route_geometry = [[sample["point"] for sample in segment] for segment in routes]
chart_indices = evenly_spaced(len(analysis["samples"]), CHART_LIMIT)
+98
View File
@@ -0,0 +1,98 @@
"""Compact five-minute moving segments; gaps over ten seconds are excluded."""
import math
from itertools import pairwise
from .analytics import numeric
SEGMENT_SECONDS = 300
def summary(intervals):
duration = sum(item["dt"] for item in intervals)
distance = sum(item["ds"] for item in intervals)
hr = [(item["hr"], item["dt"]) for item in intervals if numeric(item["hr"]) and item["hr"] > 0]
hr_time = sum(dt for _, dt in hr)
mean_speed = distance / duration if duration else None
variation = (
math.sqrt(
sum((item["ds"] / item["dt"] - mean_speed) ** 2 * item["dt"] for item in intervals)
/ duration
)
/ mean_speed
* 100
if duration and mean_speed
else None
)
drift = None
if duration >= 240 and hr_time >= duration * 0.8:
halves = [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]
elapsed = 0.0
for item in intervals:
for index, begin, end in ((0, 0, duration / 2), (1, duration / 2, duration)):
dt = max(0, min(elapsed + item["dt"], end) - max(elapsed, begin))
if dt and numeric(item["hr"]) and item["hr"] > 0:
halves[index][0] += item["ds"] * dt / item["dt"]
halves[index][1] += item["hr"] * dt
halves[index][2] += dt
elapsed += item["dt"]
if all(part[2] >= duration * 0.4 and part[0] > 0 and part[1] > 0 for part in halves):
efficiency = [ds / hr_total for ds, hr_total, _ in halves]
drift = (efficiency[0] - efficiency[1]) / efficiency[0] * 100
return {
"distance": distance,
"duration": duration,
"heart_rate": sum(value * dt for value, dt in hr) / hr_time if hr_time else None,
"elevation": sum(item["ascent"] for item in intervals)
if duration
and sum(item["dt"] for item in intervals if item["has_elevation"]) >= duration * 0.8
else None,
"pace_variability": variation,
"heart_rate_drift": drift,
"sensor_coverage": hr_time / duration * 100 if duration else None,
}
def analyze(samples):
intervals, segments, pending, elapsed, offset = [], [], [], 0.0, 0.0
for before, after in pairwise(samples):
if not all(
numeric(row.get(key)) for row in (before, after) for key in ("time", "distance")
):
continue
dt, ds = after["time"] - before["time"], after["distance"] - before["distance"]
if not 0 < dt <= 10 or not 0.5 <= ds / dt <= 15:
continue
ascent = (
max(0, after["elevation"] - before["elevation"])
if all(numeric(row.get("elevation")) for row in (before, after))
else None
)
original = {
"dt": dt,
"ds": ds,
"hr": after.get("heart_rate"),
"ascent": ascent or 0,
"has_elevation": ascent is not None,
}
intervals.append(original)
remaining = dt
while remaining > 1e-9:
portion = min(remaining, SEGMENT_SECONDS - elapsed)
pending.append(
{
**original,
"dt": portion,
"ds": ds * portion / dt,
"ascent": (ascent or 0) * portion / dt,
}
)
elapsed += portion
remaining -= portion
if elapsed >= SEGMENT_SECONDS - 1e-9:
segments.append({**summary(pending), "offset": offset})
offset += elapsed
pending, elapsed = [], 0.0
if pending:
segments.append({**summary(pending), "offset": offset})
return summary(intervals), segments
+22
View File
@@ -101,6 +101,28 @@ class PlannedRoute(db.Model):
)
class SavedAnalysis(db.Model):
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
name = db.Column(db.String(100), nullable=False)
collection = db.Column(db.String(80), nullable=False, default="My analyses")
notes = db.Column(db.Text, nullable=False, default="")
query = db.Column(db.Text, nullable=False)
pinned = db.Column(db.Boolean, nullable=False, default=False)
position = db.Column(db.Integer, nullable=False, default=0)
width = db.Column(db.String(12), nullable=False, default="half")
view = db.Column(db.String(12), nullable=False, default="chart")
created_at = db.Column(
db.DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at = db.Column(
db.DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
class Friendship(db.Model):
id = db.Column(db.Integer, primary_key=True)
low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
+23 -3
View File
@@ -30,7 +30,7 @@ from .analytics import pace_text
from .display import stored_analysis
from .extensions import db, limiter
from .history import activity_page, latest_mapped_run, overview, records_for
from .models import Activity, Friendship, GarminConnection, User
from .models import Activity, Friendship, GarminConnection, SavedAnalysis, User
from .services import activity_data, import_cache, import_upload, route_data
from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text
from .stats_analysis import EXTRA_UNITS, FUNCTIONS, GROUPS
@@ -196,16 +196,34 @@ def dashboard():
record_book=records_for(current_user.id, {row.id for row in pagination.items}),
mapped_run=latest_mapped_run(filters),
pace_text=pace_text,
analyses=db.session.scalars(
db.select(SavedAnalysis)
.filter_by(owner_id=current_user.id, pinned=True)
.order_by(SavedAnalysis.position, SavedAnalysis.id)
).all(),
)
@web.get("/stats")
@login_required
def stats():
from .analysis_library import owned, serialize
from .stats_presets import PRESETS
saved = None
if request.args.get("analysis"):
try:
analysis_id = int(request.args["analysis"])
except ValueError:
abort(404)
saved = owned(analysis_id)
args = dict(request.args)
if saved and "query" not in args:
args["query"] = saved.query
error = None
results = None
try:
query = parse_query(request.args)
query = parse_query(args)
page = int(request.args.get("page", "1"))
results = explore(current_user.id, current_user.timezone, query, page)
except (ValueError, OverflowError) as exc:
@@ -218,7 +236,9 @@ def stats():
metrics=METRICS,
results=results,
error=error,
submitted_query=request.args.get("query", ""),
submitted_query=args.get("query", ""),
selected_analysis=serialize(saved) if saved else None,
presets=PRESETS,
language={
"functions": sorted(FUNCTIONS),
"groups": sorted(GROUPS),
+91
View File
@@ -0,0 +1,91 @@
/* One renderer for the studio and live dashboard. All labels remain plain text. */
(() => {
'use strict';
const colors = ['#f48459', '#8ccbb4', '#9cb8e8', '#d2ace8', '#e0c375', '#75cad6'];
const pace = value => { const seconds = Math.round(value); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
const number = (value, unit) => value == null ? '—' : unit === '/km' ? pace(value) : new Intl.NumberFormat(undefined, {maximumFractionDigits:2}).format(value);
function element(tag, className, text) {
const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node;
}
function insights(container, rows, onBucket) {
rows.forEach(row => {
const unitName=row.source||'runs';
const panel = element('article', 'analysis-insight');
panel.append(element('h3', '', row.label));
if (!row.supported) panel.append(element('p', '', 'Baseline bands support mean/median/percentile effort metrics. Totals and counts use their own trend charts.'));
else if (!row.usable) panel.append(element('p', '', `Insufficient baseline variation or coverage (${row.count} usable ${unitName}). At least ${Math.max(10, row.rule.minimum)} are required.`));
else {
panel.append(element('p', '', `Baseline ${number(row.baseline, row.unit)} ${row.unit} · reference range ${number(row.low, row.unit)}–${number(row.high, row.unit)} · ${row.count} ${unitName} · ${row.start} to ${row.end}`));
panel.append(element('p', 'field-help', `Range: baseline summary ± ${row.rule.sd} population SD of individual matched ${unitName}. Descriptive range, not a confidence interval. Flag: ${row.rule.direction}, ≥${row.rule.minimum} usable ${unitName} per bucket, ${row.rule.sustained} consecutive buckets.`));
panel.append(element('strong', '', row.flagged.length ? `${row.flagged.length} buckets meet the deviation rule` : 'No buckets meet the deviation rule'));
const list = element('div', 'analysis-flags');
row.flagged.forEach(flag => {const button = element('button', 'button secondary', `${flag.label} · ${number(flag.value, row.unit)} ${row.unit} · ${flag.count} ${unitName}`);button.type='button';button.onclick=()=>onBucket?.(flag.label);list.append(button);});
panel.append(list);
}
container.append(panel);
});
}
function render(container, data, onBucket, {compact=false, scatterOnly=false}={}) {
const instances = [], cleanups = [];
container.replaceChildren();
const chart = (canvas, config) => {config.plugins=[...(config.plugins||[]),{id:'strideCanvasBackground',beforeDraw(c){const ctx=c.ctx;ctx.save();ctx.globalCompositeOperation='destination-over';ctx.fillStyle='#202629';ctx.fillRect(0,0,c.width,c.height);ctx.restore();}}];const instance = new Chart(canvas, config); instances.push(instance); return instance; };
const axes = (unit, reverse=false, title=unit) => ({title:{display:true,text:title,color:'#aab5b7'},reverse,ticks:{color:'#aab5b7',maxTicksLimit:6,callback:v=>number(v,unit)},grid:{color:'#354044'}});
const base = {responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#c5cecf'}}},scales:{x:{ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7},grid:{display:false}}}};
function frame(title) {
const panel = element('div', 'analysis-chart');
panel.append(element('h3', '', title));
const wrap=element('div','analysis-canvas'); const canvas=element('canvas');canvas.setAttribute('role','img');canvas.setAttribute('aria-label',title);wrap.append(canvas);panel.append(wrap);
if (!compact) {const download=element('button','text-button analysis-download','Download plot');download.type='button';download.onclick=()=>{const link=document.createElement('a');link.download='stride-analysis.png';link.href=canvas.toDataURL('image/png');link.click();};panel.append(download);}
container.append(panel);return canvas;
}
function heatmap(series) {
const panel=element('div','analysis-chart');panel.append(element('h3','',`${series.label} · ${series.unit}`));
const grid=element('div','analysis-calendar');const values=series.values.filter(v=>v!=null);const max=Math.max(...values,1);
let month='';
data.dates.forEach((date,index)=>{const current=date.slice(0,7);if(current!==month){grid.append(element('div','analysis-month',current));['M','T','W','T','F','S','S'].forEach(day=>grid.append(element('span','analysis-weekday',day)));const offset=(new Date(date+'T12:00:00Z').getUTCDay()+6)%7;for(let i=0;i<offset;i++)grid.append(element('span'));month=current;}const cell=element('button','analysis-day');cell.type='button';const value=series.values[index];cell.style.background=value==null?'#252d30':`rgba(244,132,89,${.15+.85*Math.max(0,value)/max})`;cell.append(element('span','',date.slice(8)),element('small','',number(value,series.unit)));cell.setAttribute('aria-label',`${date}: ${number(value,series.unit)} ${series.unit}`);cell.onclick=()=>onBucket?.(data.labels[index]);grid.append(cell);});panel.append(grid);container.append(panel);
}
if (!scatterOnly && data.type!=='none') {
if(data.type==='heatmap') data.series.forEach(heatmap);
else if(data.type==='stacked'){
const series=data.series[0],canvas=frame(`${series.label} · by cohort (${series.unit})`);
chart(canvas,{type:'bar',data:{labels:data.labels,datasets:data.cohorts.map((cohort,i)=>({label:cohort.label,data:cohort.values,backgroundColor:colors[i%colors.length]}))},options:{...base,scales:{x:{...base.scales.x,stacked:true},y:{...axes(series.unit),stacked:true}},plugins:{...base.plugins,tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${number(c.parsed.y,series.unit)} ${series.unit}`}}},onClick:(_,points)=>{if(points.length)onBucket?.(data.labels[points[0].index]);}}});
container.append(element('p','field-help','Each input belongs to the first matching cohort; unmatched or missing inputs are shown separately.'));
}
else if(data.type==='histogram') data.series.forEach(series=>{
const h=series.histogram;const canvas=frame(`${h.field[0]} distribution · ${h.field[1]}`);
chart(canvas,{type:'bar',data:{labels:h.counts.map((_,i)=>`${number(h.edges[i],h.field[1])}–${number(h.edges[i+1],h.field[1])}`),datasets:[{label:data.source||'Runs',data:h.counts,backgroundColor:colors[0]}]},options:{...base,scales:{...base.scales,y:axes(data.source||'runs')},plugins:{...base.plugins,legend:{display:false}},onClick:(_,points)=>{if(points.length)onBucket?.(h.buckets[points[0].index]);}}});
});
else if(data.type==='box') data.series.forEach(series=>{
const canvas=frame(`${series.label} · per-run distribution`);
const boxes=series.boxes;
const whiskers={id:'strideWhiskers',afterDatasetsDraw(c){const ctx=c.ctx;const meta=c.getDatasetMeta(0);ctx.save();ctx.strokeStyle=colors[0];ctx.lineWidth=2;boxes.forEach((b,i)=>{if(b.count===0)return;const mark=meta.data[i];const x=mark.x,y=c.scales.y;ctx.beginPath();ctx.moveTo(x,y.getPixelForValue(b.low));ctx.lineTo(x,y.getPixelForValue(b.high));for(const v of [b.low,b.high,b.median]){ctx.moveTo(x-9,y.getPixelForValue(v));ctx.lineTo(x+9,y.getPixelForValue(v));}ctx.stroke();});ctx.restore();}};
chart(canvas,{type:'bar',plugins:[whiskers],data:{labels:data.labels,datasets:[{label:'25th–75th percentile',data:boxes.map(b=>b.count?[b.q1,b.q3]:null),backgroundColor:colors[0]+'66',borderColor:colors[0],borderWidth:1},{label:'Range',data:boxes.map(b=>b.low),type:'line',pointRadius:0,borderWidth:0},{label:'Range',data:boxes.map(b=>b.high),type:'line',pointRadius:0,borderWidth:0}]},options:{...base,plugins:{legend:{display:false},tooltip:{filter:c=>c.datasetIndex===0,callbacks:{label:c=>{const b=boxes[c.dataIndex];return [`Median ${number(b.median,series.unit)} ${series.unit}`,`Quartiles ${number(b.q1,series.unit)}–${number(b.q3,series.unit)}`,`Range ${number(b.low,series.unit)}–${number(b.high,series.unit)}`,`${b.count} usable runs`];}}}},scales:{...base.scales,y:axes(series.unit)},onClick:(_,points)=>{if(points.length)onBucket?.(data.labels[points[0].index]);}}});
});
else {
const groups=data.display==='overlay'?[data.series]:data.series.map(s=>[s]);
groups.forEach(seriesGroup=>{
const canvas=frame(seriesGroup.map(s=>`${s.label} (${s.unit})`).join(' / '));const datasets=[],scales={...base.scales};const units=[...new Set(seriesGroup.map(s=>s.unit))];
units.forEach((unit,i)=>{scales[`y${i}`]={...axes(unit),position:i%2?'right':'left',grid:{color:'#354044',drawOnChartArea:i===0}};});
seriesGroup.forEach((series,index)=>{
const color=colors[index%colors.length], axis=`y${units.indexOf(series.unit)}`;const automatic=['bin','weekday','hour','run','all'].includes(data.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line';const type=data.type==='auto'?automatic:data.type==='area'?'line':data.type;
if(series.baseline?.value!=null){const b=series.baseline;datasets.push({label:'Baseline range lower',data:data.labels.map(()=>b.low),borderWidth:0,pointRadius:0,yAxisID:axis,type:'line',baseline:true},{label:'Baseline range',data:data.labels.map(()=>b.high),borderWidth:0,pointRadius:0,fill:'-1',backgroundColor:'#8ccbb41c',yAxisID:axis,type:'line',baseline:true},{label:`${series.label} baseline`,data:data.labels.map(()=>b.value),borderColor:'#8ccbb4',borderDash:[4,4],pointRadius:0,borderWidth:1,yAxisID:axis,type:'line',baseline:true});}
if(data.reference?.field===series.key)datasets.push({label:'Reference',data:data.labels.map(()=>data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true});
datasets.push({label:series.label,data:series.values,type,yAxisID:axis,borderColor:color,backgroundColor:color+(type==='bar'?'bb':'33'),borderWidth:2,pointRadius:c=>series.baseline?.flags[c.dataIndex]?6:2,pointStyle:c=>series.baseline?.flags[c.dataIndex]?'triangle':'circle',tension:.15,spanGaps:false,fill:data.type==='area',unit:series.unit});
if(series.previous.length)datasets.push({label:`${series.label} · previous`,data:series.previous,type,yAxisID:axis,borderColor:'#aab5b7',borderDash:[5,5],borderWidth:1,pointRadius:1,backgroundColor:'#aab5b722',previous:true,unit:series.unit});
});
chart(canvas,{type:'line',data:{labels:data.labels,datasets},options:{...base,scales,plugins:{legend:{labels:{color:'#c5cecf',filter:item=>!['Baseline range lower','Baseline range'].includes(item.text)}},tooltip:{filter:c=>!c.dataset.baseline,callbacks:{label:c=>`${c.dataset.label}: ${number(c.parsed.y,c.dataset.unit)} ${c.dataset.unit}`}}},onClick:(_,points,c)=>{const p=points.find(p=>!c.data.datasets[p.datasetIndex].baseline&&!c.data.datasets[p.datasetIndex].previous);if(p)onBucket?.(data.labels[p.index]);}}});
});
}
}
if(data.scatter){
const s=data.scatter,canvas=frame(`${s.x[0]} vs ${s.y[0]}${s.correlation==null?'':` · r = ${s.correlation.toFixed(3)}`}`);
const finite=values=>values.filter(v=>typeof v==='number'&&Number.isFinite(v));
const colorValues=finite(s.current.map(p=>p.color)), sizeValues=finite(s.current.map(p=>p.size));const extent=values=>values.length?[Math.min(...values),Math.max(...values)]:[0,0];const [cmin,cmax]=extent(colorValues),[smin,smax]=extent(sizeValues);
const scale=(v,min,max)=>v==null?.5:max===min?.5:Math.max(0,Math.min(1,(v-min)/(max-min)));
chart(canvas,{type:'scatter',data:{datasets:[{label:'Current',data:s.current,backgroundColor:c=>s.color?`hsl(${25+160*scale(c.raw?.color,cmin,cmax)} 65% 65%)`:colors[0],pointRadius:c=>s.size?3+6*scale(c.raw?.size,smin,smax):4},...(s.previous.length?[{label:'Previous',data:s.previous,backgroundColor:'#aab5b7',pointRadius:3}]:[])]},options:{...base,scales:{x:axes(s.x[1],false,`${s.x[0]} (${s.x[1]})`),y:axes(s.y[1],false,`${s.y[0]} (${s.y[1]})`)},plugins:{legend:{labels:{color:'#c5cecf'}},tooltip:{callbacks:{label:c=>[c.raw.label,`${number(c.raw.x,s.x[1])} ${s.x[1]} / ${number(c.raw.y,s.y[1])} ${s.y[1]}`,...(s.color?[`${s.color[0]}: ${number(c.raw.color,s.color[1])} ${s.color[1]}`]:[]),...(s.size?[`${s.size[0]}: ${number(c.raw.size,s.size[1])} ${s.size[1]}`]:[])]}}},onClick:(_,points,c)=>{if(points.length)location.assign(`/activities/${c.data.datasets[points[0].datasetIndex].data[points[0].index].id}`);}}});
if(s.color||s.size)container.append(element('p','field-help',`${s.color?`Color: ${s.color[0]} · ${number(cmin,s.color[1])} (warm) to ${number(cmax,s.color[1])} (cool). `:''}${s.size?`Size: ${s.size[0]} · ${number(smin,s.size[1])} to ${number(smax,s.size[1])}.`:''}`));
}
return ()=>{instances.forEach(c=>c.destroy());cleanups.forEach(f=>f());};
}
window.StrideCharts={render,insights,element,number};
})();
+58
View File
@@ -0,0 +1,58 @@
(() => {
'use strict';
const root=document.getElementById('stats-studio');if(!root)return;
const status=document.getElementById('studio-status'),library=document.getElementById('analysis-library');
const dialog=document.getElementById('save-analysis-dialog'),form=document.getElementById('save-analysis-form');
const initial=JSON.parse(document.getElementById('selected-analysis').textContent);
let selected=initial,editing=null,analyses=[];
const el=window.StrideCharts.element;
const query=()=>window.strideQueryEditor?.getValue()||document.getElementById('query-input').value;
async function api(path='',method='GET',body){
const response=await fetch('/stats/analyses'+path,{method,headers:{'Content-Type':'application/json','X-CSRFToken':root.dataset.csrf},...(body?{body:JSON.stringify(body)}:{})});
const payload=response.status===204?{}:await response.json();if(!response.ok)throw new Error(payload.error||'Could not save analysis. Try again.');return payload;
}
function button(text,callback){const b=el('button','text-button',text);b.type='button';b.onclick=callback;return b;}
async function load(){try{analyses=(await api()).analyses;if(selected)selected=analyses.find(a=>a.id===selected.id)||null;render();}catch(error){library.textContent=error.message;}}
function render(){
library.replaceChildren();
if(!analyses.length){library.append(el('p','field-help','Save a question to build an investigation. Your library and pinned widgets follow your account.'));return;}
const collections=[...new Set(analyses.map(a=>a.collection))];
collections.forEach(collection=>{
const group=el('details','analysis-collection');
group.append(el('summary','',`${collection} · ${analyses.filter(a=>a.collection===collection).length} analyses`));
const grid=el('div','analysis-library-grid');
analyses.filter(a=>a.collection===collection).forEach(a=>{
const card=el('article','analysis-library-item'+(selected?.id===a.id?' is-current':''));const link=el('a','analysis-library-name',a.name);link.href='/stats?'+new URLSearchParams({analysis:a.id});card.append(link);
if(a.pinned)card.append(el('span','pill','Pinned'));
if(a.notes)card.append(el('p','field-help',a.notes));
const actions=el('div','query-actions');
actions.append(button(a.pinned?'Unpin':'Pin',async()=>{try{await api('/'+a.id,'PATCH',{pinned:!a.pinned});await load();status.textContent=a.pinned?'Removed from dashboard.':'Pinned to your live dashboard.';}catch(e){status.textContent=e.message;}}),button('Duplicate',()=>open(a,true)),button('Delete',async()=>{if(!confirm(`Delete “${a.name}” and its dashboard widget?`))return;try{await api('/'+a.id,'DELETE');if(selected?.id===a.id)selected=null;await load();status.textContent='Analysis deleted.';}catch(e){status.textContent=e.message;}}));
card.append(actions);grid.append(card);
});group.append(grid);library.append(group);
});
}
function open(a=selected,copy=false,pin=false){
editing=copy?null:a;form.reset();form.elements.name.value=a?(copy?`${a.name} · copy`:a.name):'New running analysis';form.elements.collection.value=a?.collection||'My analyses';form.elements.notes.value=a?.notes||'';form.elements.view.value=a?.view||'chart';form.elements.width.value=a?.width||'half';form.elements.pinned.checked=pin||a?.pinned||false;document.getElementById('save-analysis-title').textContent=editing?'Update analysis':'Save analysis';document.getElementById('save-analysis-error').textContent='';dialog.dataset.source=copy&&a?a.query:'';dialog.showModal();
}
document.getElementById('save-view').onclick=()=>open();
document.getElementById('pin-analysis').onclick=()=>open(selected,false,true);
document.getElementById('close-analysis-dialog').onclick=()=>dialog.close();
document.getElementById('save-analysis-copy').onclick=()=>{editing=null;document.getElementById('save-analysis-title').textContent='Save new analysis';};
form.onsubmit=async event=>{
event.preventDefault();const submit=document.getElementById('save-analysis-submit');submit.disabled=true;
const payload=Object.fromEntries(new FormData(form));payload.pinned=form.elements.pinned.checked;payload.query=dialog.dataset.source||query();
try{selected=(await api(editing?'/'+editing.id:'',editing?'PATCH':'POST',payload)).analysis;dialog.close();location.assign('/stats?'+new URLSearchParams({analysis:selected.id}));}
catch(error){document.getElementById('save-analysis-error').textContent=error.message;}
finally{submit.disabled=false;}
};
document.getElementById('new-analysis').onclick=()=>location.assign('/stats');
document.getElementById('matched-effort-form').onsubmit=event=>{
event.preventDefault();const fields=Object.fromEntries(new FormData(event.target));
const text=`${fields.source}\n during last ${fields.weeks} weeks\n where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elevation_per_km <= ${fields.hills} m/km\n and has heart_rate\n${fields.source==='segments'?' and pace_variability <= 5 %\n and sensor_coverage >= 80 %\n and duration >= 4 min\n':''} summarize weighted_mean(heart_rate) as "Matched-pace HR"\n by week\n compare none\n baseline previous ${fields.baseline} days\n flag above baseline 1.5 sd min ${fields.minimum} runs sustained 2 buckets\n chart line`;
location.assign('/stats?'+new URLSearchParams({query:text}));
};
document.querySelectorAll('[data-presentation]').forEach(control=>control.onchange=()=>{
const clause=control.dataset.presentation,text=`${clause} ${control.value}`;const lines=query().split('\n');const i=lines.findIndex(line=>line.trim().startsWith(clause+' '));if(i<0)lines.push(' '+text);else lines[i]=' '+text;if(clause==='chart'&&control.value==='heatmap'){const g=lines.findIndex(line=>line.trim().startsWith('by '));if(g<0)lines.push(' by day');else lines[g]=' by day';}window.strideQueryEditor.setValue(lines.join('\n'));document.getElementById('explorer-form').requestSubmit();
});
load();
})();
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
(() => {
'use strict';
const root=document.getElementById('live-analyses');if(!root)return;
const grid=document.getElementById('live-analysis-grid'),status=document.getElementById('live-analysis-status');
const states=new Map();let refreshing=false;
const el=StrideCharts.element;
async function api(path,method='GET',payload){const response=await fetch('/stats/analyses'+path,{method,headers:{'Content-Type':'application/json','X-CSRFToken':root.dataset.csrf},...(payload?{body:JSON.stringify(payload)}:{})});const result=response.status===204?{}:await response.json();if(!response.ok)throw new Error(result.error||'Could not update this widget.');return result;}
function show(widget,result){
const id=widget.dataset.id,old=states.get(id);old?.dispose?.();
const body=widget.querySelector('.live-widget-body'),insights=widget.querySelector('.live-widget-insights');body.replaceChildren();insights.replaceChildren();
const url=(bucket,scope='group')=>'/stats?'+new URLSearchParams({analysis:id,query:result.analysis.query,...(bucket?{bucket,scope}:{})})+'#studio-evidence';
const data=result.results;
let dispose=null;
if(!data.count)body.append(el('p','field-help','No runs match this analysis yet. Explore to adjust the filters.'));
else if(widget.dataset.view==='chart'&&(data.chart.type!=='none'||data.chart.scatter))dispose=StrideCharts.render(body,data.chart,bucket=>location.assign(url(bucket,'trend')),{compact:true});
else if(widget.dataset.view==='value'||(widget.dataset.view==='chart'&&data.chart.type==='none')){
const values=el('div','live-widget-values');data.cards.forEach(card=>{const item=el('div');item.append(el('span','eyebrow',card.label),el('strong','',`${card.value} ${card.unit}`),el('small','field-help',`${card.coverage} of ${data.count} usable ${data.source}`));values.append(item);});body.append(values);
}else{
const wrap=el('div','activity-table-wrap'),table=el('table','activity-table'),head=el('thead'),tr=el('tr');['Group',data.source,...data.cards.map(c=>`${c.label} · ${c.unit}`)].forEach(t=>tr.append(el('th','',t)));head.append(tr);table.append(head);const tbody=el('tbody');data.groups.slice(-12).forEach(group=>{const row=el('tr'),cell=el('td'),a=el('a','',group.label);a.href=url(group.label);cell.append(a);row.append(cell);[group.count,...group.values].forEach(v=>row.append(el('td','',v)));tbody.append(row);});table.append(tbody);wrap.append(table);body.append(wrap);if(data.groups.length>12)body.append(el('p','field-help','Last 12 displayed groups · Explore for all results.'));
}
StrideCharts.insights(insights,data.insights,bucket=>location.assign(url(bucket)));
widget.querySelector('.widget-status').textContent=`${data.count} matching ${data.source} · ${data.start} – ${data.end} · updated ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`;
states.set(id,{result,dispose});
}
async function refresh(){
if(refreshing||document.hidden)return;refreshing=true;document.getElementById('refresh-analyses').disabled=true;
const widgets=[...grid.querySelectorAll('[data-id]')];let index=0;
async function worker(){while(index<widgets.length){const widget=widgets[index++];try{const result=await api('/'+widget.dataset.id+'/results');if(widget.isConnected)show(widget,result);}catch(error){if(widget.isConnected)widget.querySelector('.widget-status').textContent=error.message+' Open the analysis in Stats or refresh to retry.';}}}
await Promise.all([worker(),worker()]);refreshing=false;document.getElementById('refresh-analyses').disabled=false;
}
grid.addEventListener('click',async event=>{
const button=event.target.closest('button[data-widget-action]');if(!button)return;const widget=button.closest('[data-id]'),id=widget.dataset.id,action=button.dataset.widgetAction;button.disabled=true;
try{
if(action==='unpin'){await api('/'+id,'PATCH',{pinned:false});states.get(id)?.dispose?.();states.delete(id);widget.remove();status.textContent='Analysis remains in your account library.';}
else if(action==='width'){const full=!widget.classList.contains('widget-full');await api('/'+id,'PATCH',{width:full?'full':'half'});widget.classList.toggle('widget-full',full);button.textContent=full?'Half width':'Full width';}
else{
const widgets=[...grid.querySelectorAll('[data-id]')],index=widgets.indexOf(widget),next=action==='up'?index-1:index+1;if(next<0||next>=widgets.length)return;[widgets[index],widgets[next]]=[widgets[next],widgets[index]];await api('/order','POST',{ids:widgets.map(w=>Number(w.dataset.id))});widgets.forEach(w=>grid.append(w));
}
}catch(error){status.textContent=error.message;}finally{button.disabled=false;}
});
grid.addEventListener('change',async event=>{const control=event.target;if(control.dataset.widgetAction!=='view')return;const widget=control.closest('[data-id]'),before=widget.dataset.view;control.disabled=true;try{await api('/'+widget.dataset.id,'PATCH',{view:control.value});widget.dataset.view=control.value;const state=states.get(widget.dataset.id);if(state)show(widget,state.result);}catch(error){control.value=before;status.textContent=error.message;}finally{control.disabled=false;}});
document.getElementById('refresh-analyses').onclick=refresh;
let lastFocus=0;window.addEventListener('focus',()=>{if(Date.now()-lastFocus>60000){lastFocus=Date.now();refresh();}});
const interval=setInterval(refresh,300000);window.addEventListener('pagehide',()=>{clearInterval(interval);states.forEach(state=>state.dispose?.());});
refresh();
})();
+15 -3
View File
@@ -24,6 +24,10 @@
const templates = [
['runs', 'Your recorded running activities'],
['laps', 'Recorded lap inputs from compact analysis'],
['segments', 'Five-minute moving segments from full-resolution recordings'],
['cohort "Easy" where heart_rate <= 145 bpm', 'First-match partitions for a stacked additive summary'],
['compare previous year', 'Same local-date interval in the preceding year'],
['during last 12 weeks', 'Rolling period ending today'],
['during last 6 months', 'Calendar months'],
['during last 30 days', 'Rolling days'],
@@ -41,7 +45,14 @@
['trend change', 'Difference from the preceding bucket'],
['trend percent_change', 'Percentage change from the preceding bucket'],
['plot speed vs heart_rate', 'Run-level scatter plot and Pearson correlation'],
['chart bar', 'Choose auto, line, bar or none'],
['chart bar', 'Choose auto, line, bar, area, histogram, box, heatmap or none'],
['display overlay', 'Plot multiple series together with axes by unit'],
['baseline previous 90 days', 'Earlier matched-run baseline before the query starts'],
['flag above baseline 1.5 sd min 3 runs sustained 2 buckets', 'Persistent descriptive deviation with minimum coverage'],
['color by elevation_per_km', 'Scatter color encodes this numeric metric'],
['size by distance', 'Scatter size encodes this numeric metric'],
['reference heart_rate = 150 bpm', 'Draw a reference line for a selected metric'],
['let hr_14d = rolling_mean(heart_rate, 14 days)', 'Custom rolling mean, sum or count before filtering'],
['by distance bins 5 km', 'Group runs into half-open distance ranges'],
['during 2026-07-01 to 2026-09-30', 'Inclusive local-date range'],
['ending 2026-10-02', 'Optional end date for a rolling period'],
@@ -71,7 +82,7 @@
const word = stream.current();
if (names.has(word) || availableMetrics().some(metric=>metric.name===word)) return 'variable-2';
if (language.functions.includes(word)) return 'builtin';
if (/^(runs|during|last|ending|where|and|or|between|summarize|by|compare|previous|period|none|has|missing|title|contains|sort|asc|desc|to|let|having|rank|take|limit|trend|rolling|cumulative|change|percent_change|plot|vs|chart|bins|all|time)$/.test(word)) return 'keyword';
if (/^(runs|during|last|ending|where|and|or|between|summarize|by|compare|previous|period|none|has|missing|title|contains|sort|asc|desc|to|let|having|rank|take|limit|trend|rolling|cumulative|change|percent_change|plot|vs|chart|bins|all|time|baseline|flag|sd|min|sustained|buckets|above|below|outside|display|overlay|separate|color|size|reference|as)$/.test(word)) return 'keyword';
if (/^(day|days|week|weeks|month|months|km|m|bpm|spm|W|ms|h|min|s|date)$/.test(word)) return 'atom';
return null;
}
@@ -89,7 +100,7 @@
} else if (/^\s*rank by (?:\w+|\w+\(\w*\))\s+\w*$/.test(line)) {
list=['asc','desc'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Group ranking order'}));
} else if (/^\s*chart\s+\w*$/.test(line)) {
list=['auto','line','bar','none'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Chart presentation'}));
list=['auto','line','bar','area','histogram','box','heatmap','stacked','none'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Chart presentation'}));
} else if (/^\s*trend\s+[\w ]*$/.test(line)) {
from={line:cursor.line,ch:line.indexOf('trend')+6};const prefix=line.slice(from.ch);
list=['cumulative','rolling 4 buckets','change','percent_change'].filter(text=>text.startsWith(prefix)).map(text=>({text,detail:'Transform displayed time buckets'}));
@@ -192,6 +203,7 @@
// Submit only the authoritative DSL. Helper controls never override it.
const url=new URL(form.action||location.pathname,location.origin);
url.search=new URLSearchParams({query:text});
const analysis=new URL(location.href).searchParams.get('analysis');if(analysis)url.searchParams.set('analysis',analysis);
location.assign(url);
});
document.getElementById('format-query').onclick=async()=>{
+11 -57
View File
@@ -2,48 +2,22 @@
'use strict';
const root = document.getElementById('stats-studio');
if (!root) return;
const form = document.getElementById('explorer-form');
const input = document.getElementById('query-input');
const status = document.getElementById('studio-status');
const value = name => form.elements.namedItem(name).value;
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 = window.strideQueryEditor ? window.strideQueryEditor.getValue() : 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');
let selectionScope = 'trend';
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(); };
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 ${root.dataset.source}`; status.textContent = ''; };
period.onchange = () => { if (period.value) loadBucket(period.value,1,selectionScope); else clear.click(); };
async function loadBucket(bucket, page = 1, scope = 'trend') {
selectionScope=scope;
pending?.abort();
const controller = new AbortController(); pending = controller;
const url = new URL('/stats/runs', location.origin);
@@ -67,7 +41,7 @@
[`${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`;
document.getElementById('evidence-heading').textContent = `${bucket} · ${result.count} ${root.dataset.source}`;
period.value = bucket;
pagination.replaceChildren();
const label = document.createElement('span'); label.textContent = `Page ${result.page} of ${result.pages} · selected period`; pagination.append(label);
@@ -78,30 +52,10 @@
} catch (error) { if (error.name !== 'AbortError') status.textContent = 'Could not load this period. Try again.'; }
}
root.querySelectorAll('[data-bucket]').forEach(button=>button.onclick=()=>loadBucket(button.dataset.bucket,1,'group'));
chartData.series.forEach((series, index) => {
const canvas = document.getElementById(`stats-chart-${index}`);
if (!canvas) return;
new Chart(canvas, {
type: chartData.type === 'auto' ? (['bin','weekday','hour','run','all'].includes(chartData.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line') : chartData.type,
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.pace_axis ? pace(c.parsed.y) : c.parsed.y.toFixed(2)} ${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.pace_axis?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);
}
}
});
});
const scatter=chartData.scatter;
const scatterCanvas=document.getElementById('stats-scatter');
if(scatter&&scatterCanvas){
const numeric=(n,info)=>info[1]==='/km'?pace(n):new Intl.NumberFormat(undefined,{maximumFractionDigits:2}).format(n);
new Chart(scatterCanvas,{type:'scatter',data:{datasets:[{label:'Current',data:scatter.current,backgroundColor:'#f48459',pointRadius:4},...(scatter.previous.length?[{label:'Previous',data:scatter.previous,backgroundColor:'#9ba4a5',pointRadius:3}]:[])]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#bcc4c5'}},tooltip:{callbacks:{label:c=>`${c.raw.label}: ${numeric(c.raw.x,scatter.x)} ${scatter.x[1]} / ${numeric(c.raw.y,scatter.y)} ${scatter.y[1]}`}}},scales:{x:{title:{display:true,text:`${scatter.x[0]} (${scatter.x[1]})`,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>numeric(n,scatter.x)},grid:{color:'#303638'}},y:{title:{display:true,text:`${scatter.y[0]} (${scatter.y[1]})`,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>numeric(n,scatter.y)},grid:{color:'#303638'}}},onClick:(_,elements,chart)=>{const point=elements[0];if(point){const row=chart.data.datasets[point.datasetIndex].data[point.index];location.assign(`/activities/${row.id}`);}}}});
}
const plots=document.getElementById('analysis-plots');
if(plots)StrideCharts.render(plots,chartData,bucket=>loadBucket(bucket));
const insightPanel=document.getElementById('analysis-insights');
if(insightPanel)StrideCharts.insights(insightPanel,JSON.parse(document.getElementById('insights-data').textContent),bucket=>loadBucket(bucket,1,'group'));
const initialBucket=new URL(location.href).searchParams.get('bucket');
if(initialBucket)loadBucket(initialBucket,1,new URL(location.href).searchParams.get('scope')||'trend');
})();
+54 -13
View File
@@ -61,6 +61,16 @@ DEFAULT = {
"derived": [],
"aggregates": {},
"filter_groups": [],
"display": "separate",
"baseline": None,
"flag": None,
"color": None,
"size": None,
"labels": {},
"reference": None,
"source": "runs",
"cohorts": [],
"comparison": "period",
}
@@ -121,7 +131,7 @@ def query_text(q):
period = f"during last {q['days']} days"
else:
period = f"during last {q['weeks']} weeks"
lines = ["runs", " " + period]
lines = [q["source"], " " + period]
for spec in q["derived"]:
lines.append(f" let {spec['name']} = {spec['expression']}")
if q["end"] and not q["start"]:
@@ -166,7 +176,11 @@ def query_text(q):
)
for condition in q["filters"]:
lines.append(clause(condition))
metrics = ", ".join(query_metrics(q))
metrics = ", ".join(
key
+ (f" as {json.dumps(q['labels'][key], ensure_ascii=False)}" if key in q["labels"] else "")
for key in query_metrics(q)
)
lines.extend(
[
f" summarize {metrics}",
@@ -175,7 +189,14 @@ def query_text(q):
if q["bin"]
else f" by {q['group']}"
),
" compare " + ("previous period" if q["compare"] else "none"),
" compare "
+ (
"previous year"
if q["comparison"] == "year"
else "previous period"
if q["compare"]
else "none"
),
]
)
if q["sort"] != ["date", "desc"]:
@@ -188,7 +209,7 @@ def query_text(q):
"runs"
if spec and spec["function"] == "count"
else "sec/km"
if spec and field == "pace" and spec["function"] in {"sum", "stddev"}
if spec and field in {"pace", "gap"} and spec["function"] in {"sum", "stddev"}
else next(iter(units[field]))
)
lines.append(f" having {key} {condition['op']} {number_text(condition['value'])} {unit}")
@@ -203,6 +224,25 @@ def query_text(q):
lines.append(f" plot {q['plot'][0]} vs {q['plot'][1]}")
if q["chart"] != "auto":
lines.append(f" chart {q['chart']}")
if q["display"] != "separate":
lines.append(f" display {q['display']}")
if q["baseline"]:
lines.append(f" baseline previous {q['baseline']} days")
if q["flag"]:
flag = q["flag"]
lines.append(
f" flag {flag['direction']} baseline {number_text(flag['sd'])} sd min {flag['minimum']} runs sustained {flag['sustained']} buckets"
)
for key in ("color", "size"):
if q[key]:
lines.append(f" {key} by {q[key]}")
if q["reference"]:
ref = q["reference"]
lines.append(f" reference {ref['field']} = {ref['raw']} {ref['unit']}")
for cohort in q["cohorts"]:
lines.append(
f" cohort {json.dumps(cohort['name'], ensure_ascii=False)} where {cohort['source']}"
)
return "\n".join(lines)
@@ -254,16 +294,17 @@ def metric_value(row, key):
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]
raw = getattr(
row,
{"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[key],
)
return (row.analysis_summary or {}).get(key)
return (
raw / {"distance": 1000, "duration": 3600, "elevation": 1}[key]
if isinstance(raw, (int, float)) and math.isfinite(raw)
else None
)
summary = row.analysis_summary or {}
return summary.get("summary", summary).get(key)
def aggregate(rows, key):
+73 -6
View File
@@ -25,6 +25,26 @@ FUNCTIONS = {
}
GROUPS = {"day", "week", "month", "year", "weekday", "hour", "run", "all"}
EXTRA_METRICS = {
"pace_variability": (
"Speed variability",
"%",
"Duration-weighted speed coefficient of variation across usable moving recording intervals. Lower means steadier recorded speed.",
),
"heart_rate_drift": (
"Speed/HR decoupling",
"%",
"Percent reduction in speed-to-HR efficiency from the first to second moving-time half. Positive means lower efficiency later. Requires four moving minutes and 80% HR coverage in each half; includes warm-up and terrain changes.",
),
"sensor_coverage": (
"Moving HR coverage",
"%",
"Percent of usable moving recording time with positive recorded heart rate. Gaps over ten seconds and pauses are excluded.",
),
"gap": (
"Grade-adjusted pace",
"/km",
"Modelled grade-adjusted pace from persisted recording analysis using Minetti running cost. Missing terrain stays missing.",
),
"count": ("Runs", "runs", "Number of runs."),
"speed": ("Speed", "km/h", "Recorded distance divided by timer duration."),
"gap_since_previous_run": (
@@ -91,6 +111,10 @@ EXTRA_METRICS = {
),
}
EXTRA_UNITS = {
"pace_variability": {"%": 1},
"heart_rate_drift": {"%": 1},
"sensor_coverage": {"%": 1},
"gap": {"/km": 1},
"count": {"runs": 1},
"speed": {"km/h": 1, "m/s": 3.6},
"gap_since_previous_run": {"days": 1, "h": 1 / 24, "min": 1 / 1440},
@@ -131,7 +155,7 @@ def expression_tree(source, catalog, line=1):
# Pace values inside arithmetic are seconds per km, not a formatted string.
return (
"sec/km"
if node.id == "pace"
if catalog[node.id][1] == "/km"
else "h"
if node.id == "duration"
else catalog[node.id][1]
@@ -199,6 +223,9 @@ def evaluate(tree, values):
def parse_analysis(text, metrics, defaults):
if not isinstance(text, str) or len(text) > 4000:
raise QueryError("Keep the query under 4,000 characters.")
from .stats_features import extract, validate
text, features = extract(text)
catalog, units = dict(metrics), {**UNITS, **EXTRA_UNITS}
lines = text.splitlines()
derived, aggregates, seen, locations = [], {}, set(), {}
@@ -258,14 +285,26 @@ def parse_analysis(text, metrics, defaults):
raise QueryError(
"Choose a new lowercase metric name; at most eight calculations.", number
)
_, unit = expression_tree(expression, catalog, number)
window = re.fullmatch(r"rolling_(mean|sum|count)\((\w+), (\d+) days\)", expression)
if window:
function, field, days = window.groups()
if field not in catalog or not 1 <= int(days) <= 365:
raise QueryError(
"Rolling functions need an earlier metric and 1–365 days.", number
)
unit = "runs" if function == "count" else catalog[field][1]
else:
_, unit = expression_tree(expression, catalog, number)
catalog[name] = (
name.replace("_", " ").capitalize(),
unit,
f"Calculated per run: {expression}. Missing inputs or division by zero give missing data.",
)
units[name] = {unit: 1}
derived.append({"name": name, "expression": expression, "unit": unit})
spec = {"name": name, "expression": expression, "unit": unit}
if window:
spec["window"] = {"function": function, "field": field, "days": int(days)}
derived.append(spec)
lines[index] = ""
elif line == "during all time":
once("all-time period", number)
@@ -335,14 +374,14 @@ def parse_analysis(text, metrics, defaults):
{"runs": 1}
if function == "count"
else {"sec/km": 1}
if field == "pace" and function in {"sum", "stddev"}
if catalog[field][1] == "/km" and function in {"sum", "stddev"}
else units[field]
)
unit = unit or next(iter(accepted))
if unit not in accepted:
raise QueryError(f"Use {' or '.join(accepted)} for {key}.", number)
try:
if field == "pace" and unit == "/km" and ":" in raw:
if unit == "/km" and ":" in raw:
minutes, seconds = raw.split(":")
if not re.fullmatch(r"\d+:\d{2}", raw) or int(seconds) >= 60:
raise ValueError
@@ -394,6 +433,7 @@ def parse_analysis(text, metrics, defaults):
)
q = parse_dsl("\n".join(lines), catalog, defaults, units=units)
q.update(options)
q.update(features)
q["derived"], q["aggregates"] = derived, aggregates
if group:
q["group"] = group
@@ -440,6 +480,33 @@ def parse_analysis(text, metrics, defaults):
groups[-1].extend(batch)
position += count
q["filter_groups"], q["filters"] = groups, []
if q["reference"]:
ref = q["reference"]
checked = parse_dsl(
f"runs\nwhere {ref['field']} = {ref['raw']} {ref['unit']}\nsummarize {ref['field']}",
catalog,
defaults,
units,
)
ref["value"] = checked["filters"][0]["value"]
if ref["field"] not in selected:
raise QueryError("Include the reference metric in summarize.")
for cohort in q["cohorts"]:
match = re.fullmatch(r"(\w+) (.+)", cohort["source"])
if not match or match[1] not in catalog:
raise QueryError("Cohorts need a numeric metric condition.")
field, condition = match.groups()
cohort_catalog = {**catalog, "cohort_input": catalog[field]}
checked = parse_dsl(
f"runs\nwhere cohort_input {condition}\nsummarize {field}",
cohort_catalog,
defaults,
{**units, "cohort_input": units[field]},
)
cohort["conditions"] = [{**item, "field": field} for item in checked["filters"]]
if not cohort["conditions"]:
raise QueryError("Supply a cohort comparison with a value and unit.")
validate(q, catalog)
return q
@@ -454,7 +521,7 @@ def describe(key, catalog, aggregates):
else f"{function.replace('_', ' ').capitalize()} · {catalog[field][0].lower()}"
)
unit = "runs" if function == "count" else catalog[field][1]
if field == "pace" and function in {"stddev", "sum"}:
if field and catalog[field][1] == "/km" and function in {"stddev", "sum"}:
unit = "sec/km"
definition = (
(
+1 -1
View File
@@ -55,7 +55,7 @@ def parse_dsl(text, metrics, defaults, units=None):
def amount(key, raw, unit, line):
if unit not in units[key]:
raise QueryError(f"Use {' or '.join(units[key])} for {key}.", line)
if key == "pace" and ":" in raw:
if unit == "/km" and ":" in raw:
minutes, seconds = raw.split(":")
if not minutes.isdecimal() or not seconds.isdecimal() or int(seconds) >= 60:
raise QueryError("Write pace like 5:30 /km (seconds from 00 to 59).", line)
+247
View File
@@ -0,0 +1,247 @@
"""Presentation and matched-cohort baseline clauses for the analysis language."""
import ast
import json
import re
from .stats_dsl import QueryError
CHARTS = {"auto", "line", "bar", "area", "histogram", "box", "heatmap", "stacked", "none"}
def summary_parts(source):
parts, start, quoted, escaped = [], 0, False, False
for index, char in enumerate(source):
if escaped:
escaped = False
elif char == "\\" and quoted:
escaped = True
elif char == '"':
quoted = not quoted
elif char == "," and not quoted:
parts.append(source[start:index])
start = index + 1
parts.append(source[start:])
return parts
def extract(text):
options = {
"display": "separate",
"baseline": None,
"flag": None,
"color": None,
"size": None,
"labels": {},
"reference": None,
"source": "runs",
"cohorts": [],
"comparison": "period",
}
seen, lines = set(), text.splitlines()
def once(key, line):
if key in seen:
raise QueryError(f"Repeated {key} clause.", line)
seen.add(key)
for index, source in enumerate(lines):
line, number = source.strip(), index + 1
if line in {"runs", "laps", "segments"}:
once("source", number)
options["source"] = line
lines[index] = "runs"
continue
elif line == "compare previous year":
options["comparison"] = "year"
lines[index] = "compare previous period"
continue
elif match := re.fullmatch(r'cohort ("(?:[^"\\]|\\.)*") where (.+)', line):
name = json.loads(match[1])
if (
not 1 <= len(name) <= 60
or len(options["cohorts"]) >= 4
or name in {c["name"] for c in options["cohorts"]}
):
raise QueryError(
"Choose up to four different cohort names of 1–60 characters.", number
)
options["cohorts"].append({"name": name, "source": match[2]})
elif match := re.fullmatch(r"display (overlay|separate)", line):
once("display", number)
options["display"] = match[1]
elif match := re.fullmatch(r"baseline previous (\d+) days", line):
once("baseline", number)
days = int(match[1])
if not 7 <= days <= 732:
raise QueryError("Use a baseline of 7–732 days.", number)
options["baseline"] = days
elif match := re.fullmatch(
r"flag (above|below|outside) baseline (\S+) sd min (\d+) runs sustained (\d+) buckets",
line,
):
once("flag", number)
try:
sd = float(match[2])
minimum, sustained = int(match[3]), int(match[4])
if not 0.5 <= sd <= 5 or not 2 <= minimum <= 1000 or not 1 <= sustained <= 12:
raise ValueError
except ValueError:
raise QueryError(
"Use 0.5–5 sd, 2–1,000 runs and 1–12 consecutive buckets.", number
) from None
options["flag"] = {
"direction": match[1],
"sd": sd,
"minimum": minimum,
"sustained": sustained,
}
elif match := re.fullmatch(r"(color|size) by (\w+)", line):
once(match[1], number)
options[match[1]] = match[2]
elif match := re.fullmatch(r"reference (\w+) = (\S+) (\S+)", line):
once("reference", number)
options["reference"] = {"field": match[1], "raw": match[2], "unit": match[3]}
elif line.startswith("summarize "):
parts = []
for part in summary_parts(line[10:]):
if match := re.fullmatch(r'\s*(.+?)\s+as\s+("(?:[^"\\]|\\.)*")\s*', part):
key, encoded_label = match.groups()
key = re.sub(r"\s+", "", key)
try:
label = json.loads(encoded_label)
if not 1 <= len(label.strip()) <= 80:
raise ValueError
except ValueError:
raise QueryError(
"Use a summary label of 1–80 characters in double quotes.", number
) from None
if key in options["labels"]:
raise QueryError("Choose different summary expressions.", number)
options["labels"][key] = label
part = key
parts.append(part.strip())
lines[index] = "summarize " + ", ".join(parts)
continue
elif match := re.fullmatch(r"chart (\w+)", line):
if match[1] not in CHARTS:
raise QueryError(
"Choose auto, line, bar, area, histogram, box, heatmap, stacked or none.",
number,
)
once("chart", number)
options["chart"] = match[1]
else:
continue
lines[index] = ""
return "\n".join(lines), options
def validate(q, catalog):
if q["plot"] and q["chart"] == "none":
raise QueryError("A scatter plot conflicts with chart none.")
if q["cohorts"] and (
q["chart"] != "stacked"
or q["secondary"] != "none"
or q["extra_metrics"]
or q["trend"]
or q["baseline"]
):
raise QueryError(
"Cohorts require chart stacked, one summary metric and no trend or baseline."
)
if q["chart"] == "stacked" and not q["cohorts"]:
raise QueryError('Add named cohorts, e.g. cohort "Easy" where heart_rate <= 145 bpm.')
if q["cohorts"]:
key = q["metric"]
aggregate = q["aggregates"].get(key, {})
if aggregate.get("function") not in {"sum", "count"} and key not in {
"distance",
"duration",
"elevation",
"count",
"calories",
}:
raise QueryError(
"Stack additive metrics: sum, count, distance, duration, elevation or calories."
)
if q["compare"]:
raise QueryError("Stacked cohorts show the current period; use compare none.")
if q["source"] != "runs":
temporal = {
"gap_since_previous_run",
"distance_change",
"pace_change",
"heart_rate_change",
"rolling_7d_distance",
"rolling_28d_distance",
"rolling_7d_count",
"rolling_28d_count",
"run_day_streak",
}
fields = {
q["metric"],
q["secondary"],
*q["extra_metrics"],
q["sort"][0],
q["color"],
q["size"],
*(q["plot"] or []),
}
fields.update(spec["field"] for spec in q["aggregates"].values())
for conditions in [
q["filters"],
*q["filter_groups"],
*(cohort["conditions"] for cohort in q["cohorts"]),
]:
fields.update(condition["field"] for condition in conditions)
if q["bin"]:
fields.add(q["bin"]["field"])
for spec in q["derived"]:
if "window" not in spec:
fields.update(
node.id
for node in ast.walk(ast.parse(spec["expression"], mode="eval"))
if isinstance(node, ast.Name)
)
if fields & temporal or any("window" in spec for spec in q["derived"]):
raise QueryError(
"Run-history metrics and rolling calculations require the runs source."
)
for key in ("color", "size"):
if q[key] and (not q["plot"] or q[key] not in catalog):
raise QueryError(f"{key} by requires a scatter plot and a numeric metric.")
if q["flag"] and not q["baseline"]:
raise QueryError("Add baseline previous N days before defining a flag.")
if q["flag"] and (q["having"] or q["rank"] or q["limit"]):
raise QueryError(
"Persistent flags need every calendar bucket; remove having, rank and limit."
)
if q["baseline"] and (
q["all_time"] or q["group"] not in {"day", "week", "month", "year"} or q["trend"]
):
raise QueryError(
"Baselines need a bounded time period, calendar grouping and no trend transform."
)
if q["baseline"]:
additive = {"count", "distance", "duration", "elevation", "calories"}
selected = [
q["metric"],
*([] if q["secondary"] == "none" else [q["secondary"]]),
*q["extra_metrics"],
]
supported = lambda key: (
q["aggregates"][key]["function"] not in {"sum", "count", "stddev"}
if key in q["aggregates"]
else key not in additive
)
if not any(supported(key) for key in selected):
raise QueryError("Baselines require a mean, median, percentile or non-additive metric.")
if q["chart"] == "heatmap" and q["group"] != "day":
raise QueryError("Calendar heatmaps require by day.")
if q["chart"] == "heatmap" and (q["having"] or q["rank"] or q["limit"]):
raise QueryError("Calendar heatmaps need every day; remove having, rank and limit.")
if q["chart"] in {"box", "histogram"} and q["trend"]:
raise QueryError("Distribution charts show per-run inputs; remove the trend transform.")
if q["chart"] in {"box", "histogram", "heatmap"} and q["display"] == "overlay":
raise QueryError("Use display separate for distributions and calendar heatmaps.")
+126
View File
@@ -0,0 +1,126 @@
"""Transparent, descriptive deviations from a prior matched-run cohort."""
from datetime import timedelta
from .stats_analysis import statistic, valid
def enrich(chart, q, keys, tokens, groups, history, included, start, measure, catalog):
baseline_rows = [
row
for row in history
if q["baseline"]
and start - timedelta(days=q["baseline"]) <= row.day < start
and included(row)
]
insights = []
for key, series in zip(keys, chart["series"], strict=True):
spec = q["aggregates"].get(key)
field = spec["field"] if spec else key
field = field or "count"
inputs = lambda rows, field=field: [
row.values[field] for row in rows if valid(row.values.get(field))
]
if q["chart"] == "histogram":
values = inputs([row for token in tokens for row in groups[token]])
if values:
import math
count = min(30, max(1, math.ceil(math.sqrt(len(values)))))
lo, hi = min(values), max(values)
width = (hi - lo) / count if hi > lo else max(abs(lo) * 0.05, 1)
bins = [0] * count
for number in values:
bins[min(count - 1, int((number - lo) / width))] += 1
series["histogram"] = {
"edges": [lo + i * width for i in range(count + 1)],
"counts": bins,
"width": width,
"field": catalog[field],
}
else:
series["histogram"] = {"edges": [], "counts": [], "field": catalog[field]}
if q["chart"] == "box":
series["boxes"] = [
{
"low": statistic(inputs(groups[token]), "min"),
"q1": statistic(inputs(groups[token]), "p25"),
"median": statistic(inputs(groups[token]), "median"),
"q3": statistic(inputs(groups[token]), "p75"),
"high": statistic(inputs(groups[token]), "max"),
"count": len(inputs(groups[token])),
}
for token in tokens
]
if not q["baseline"]:
continue
# Comparing totals/counts to a distribution of individual runs is meaningless.
supported = (
spec["function"] not in {"sum", "count", "stddev"}
if spec
else field not in {"count", "distance", "duration", "elevation", "calories"}
)
if not supported:
continue
numbers = inputs(baseline_rows)
config = q["flag"] or {"sd": 2, "minimum": 3, "sustained": 1, "direction": "outside"}
usable = supported and len(numbers) >= max(10, config["minimum"]) and len(set(numbers)) > 1
center = measure(baseline_rows, key) if usable else None
spread = statistic(numbers, "stddev") if usable else None
low = center - config["sd"] * spread if valid(center) else None
high = center + config["sd"] * spread if valid(center) else None
counts = [len(inputs(groups[token])) for token in tokens]
flags, streak, last_direction = [], 0, None
for number, count in zip(series["values"], counts, strict=True):
direction = (
"above"
if usable and valid(number) and number > high
else "below"
if usable and valid(number) and number < low
else None
)
qualified = (
direction
and count >= config["minimum"]
and config["direction"] in {direction, "outside"}
)
streak = (
streak + 1 if qualified and direction == last_direction else 1 if qualified else 0
)
last_direction = direction if qualified else None
flags.append(bool(q["flag"] and streak >= config["sustained"]))
series["baseline"] = {
"value": center,
"low": low,
"high": high,
"count": len(numbers),
"counts": counts,
"flags": flags,
"supported": supported,
}
flagged = [
{"label": label, "value": number, "count": count}
for label, number, count, flag in zip(
chart["labels"], series["values"], counts, flags, strict=True
)
if flag
]
insights.append(
{
"key": key,
"label": series["label"],
"unit": series["unit"],
"baseline": center,
"source": q["source"],
"low": low,
"high": high,
"count": len(numbers),
"usable": usable,
"supported": supported,
"start": (start - timedelta(days=q["baseline"])).isoformat(),
"end": (start - timedelta(days=1)).isoformat(),
"flagged": flagged,
"rule": config,
}
)
return insights
+94
View File
@@ -0,0 +1,94 @@
"""Editable analyses; never tied to a fixed calendar date."""
PRESETS = [
{
"name": "HR in steady segments",
"category": "Effort & recovery",
"description": "Match five-minute moving segments by pace, speed consistency and HR coverage.",
"query": 'segments\n during last 12 weeks\n where pace between 5:20 and 5:40 /km\n and pace_variability <= 5 %\n and sensor_coverage >= 80 %\n and duration >= 4 min\n summarize weighted_mean(heart_rate) as "Steady-segment HR"\n by week\n compare none\n baseline previous 90 days\n flag above baseline 1.5 sd min 5 runs sustained 2 buckets\n chart line',
},
{
"name": "Speed/HR decoupling",
"category": "Effort & recovery",
"description": "First-half versus second-half efficiency, alongside pace consistency and HR coverage.",
"query": "runs\n during last 12 weeks\n where duration >= 30 min\n and sensor_coverage >= 80 %\n summarize heart_rate_drift, pace_variability\n by week\n compare none\n chart line\n reference heart_rate_drift = 0 %",
},
{
"name": "Easy and harder volume",
"category": "Training",
"description": "Partition weekly distance by a heart-rate threshold you can edit.",
"query": 'runs\n during last 12 weeks\n summarize sum(distance)\n by week\n compare none\n cohort "Lower HR" where heart_rate <= 145 bpm\n cohort "Higher HR" where heart_rate > 145 bpm\n chart stacked',
},
{
"name": "Lap consistency",
"category": "Distributions",
"description": "Distribution of recorded lap paces, grouped by week.",
"query": "laps\n during last 12 weeks\n summarize pace\n by week\n compare none\n chart box",
},
{
"name": "Year over year",
"category": "Training",
"description": "Compare monthly volume with the same local-date period last year.",
"query": "runs\n during last 6 months\n summarize distance\n by month\n compare previous year\n chart area",
},
{
"name": "HR at comparable pace",
"category": "Effort & recovery",
"description": "Match pace and hills; compare weekly HR with your earlier matched runs.",
"query": 'runs\n during last 12 weeks\n where pace between 5:20 and 5:40 /km\n and elevation_per_km <= 10 m/km\n and has heart_rate\n summarize weighted_mean(heart_rate) as "Matched-pace HR"\n by week\n compare none\n baseline previous 90 days\n flag above baseline 1.5 sd min 3 runs sustained 2 buckets\n chart line',
},
{
"name": "Pace at comparable HR",
"category": "Effort & recovery",
"description": "Track pace within a consistent heart-rate band.",
"query": "runs\n during last 6 months\n where heart_rate between 140 and 150 bpm\n and elevation_per_km <= 10 m/km\n summarize weighted_mean(pace)\n by week\n compare none\n baseline previous 90 days\n chart line",
},
{
"name": "Workload context",
"category": "Training",
"description": "Rolling 7- and 28-day distance alongside every run.",
"query": "runs\n during last 12 weeks\n summarize rolling_7d_distance, rolling_28d_distance\n by run\n sort by date asc\n compare none\n chart line\n display overlay",
},
{
"name": "Weekly volume",
"category": "Training",
"description": "Distance and run frequency with the previous period.",
"query": 'runs\n during last 12 weeks\n summarize sum(distance) as "Weekly distance", count()\n by week\n compare previous period\n chart bar',
},
{
"name": "Training calendar",
"category": "Training",
"description": "A daily distance heatmap with drill-down to each day's runs.",
"query": "runs\n during last 6 months\n summarize distance\n by day\n compare none\n chart heatmap",
},
{
"name": "Pace consistency",
"category": "Distributions",
"description": "Weekly pace distributions, including quartiles and range.",
"query": "runs\n during last 12 weeks\n summarize pace\n by week\n compare none\n chart box",
},
{
"name": "Run distance distribution",
"category": "Distributions",
"description": "See the mix of short, medium and long runs.",
"query": "runs\n during last 6 months\n summarize distance\n by all\n compare none\n chart histogram",
},
{
"name": "Effort relationships",
"category": "Relationships",
"description": "Pace versus HR, colored by terrain and sized by distance.",
"query": "runs\n during last 6 months\n summarize pace, heart_rate\n by week\n compare none\n plot pace vs heart_rate\n color by elevation_per_km\n size by distance",
},
{
"name": "Custom rolling effort",
"category": "Effort & recovery",
"description": "A 14-day mean HR calculated from complete history before filtering.",
"query": "runs\n during last 12 weeks\n let hr_14d = rolling_mean(heart_rate, 14 days)\n summarize hr_14d, heart_rate\n by run\n sort by date asc\n compare none\n chart line\n display overlay",
},
{
"name": "Weekday patterns",
"category": "Distributions",
"description": "Compare typical distance and pace by weekday.",
"query": "runs\n during last 6 months\n summarize median(distance), median(pace), count()\n by weekday\n having count() >= 3 runs\n compare none\n chart bar",
},
]
+61
View File
@@ -0,0 +1,61 @@
"""Expand persisted compact laps or moving segments without reading raw recordings."""
from copy import copy
from .stats_analysis import evaluate, expression_tree, valid
from .stats_dsl import QueryError
def expand(history, q, catalog):
rows = []
trees = [
(spec["name"], expression_tree(spec["expression"], catalog)[0]) for spec in q["derived"]
]
for activity in history:
for index, unit in enumerate((activity.analysis_summary or {}).get(q["source"], [])):
if (
not isinstance(unit, dict)
or not valid(unit.get("distance"))
or not valid(unit.get("duration"))
or unit["duration"] <= 0
or unit["distance"] <= 0
):
continue
row = copy(activity)
row.activity_id = activity.id
row.id = activity.id * 1000000 + index
row.title = (
f"{activity.title} · {'lap' if q['source'] == 'laps' else 'segment'} {index + 1}"
)
row.distance_m, row.duration_s = unit["distance"], unit["duration"]
row.elevation_m = unit.get("elevation")
row.avg_hr = (
unit.get("heart_rate")
if valid(unit.get("heart_rate")) and unit["heart_rate"] > 0
else None
)
row.values = {key: None for key in catalog}
row.values.update(
count=1,
distance=row.distance_m / 1000,
duration=row.duration_s / 3600,
pace=row.duration_s * 1000 / row.distance_m,
speed=row.distance_m / row.duration_s * 3.6,
heart_rate=row.avg_hr,
elevation=row.elevation_m,
elevation_per_km=row.elevation_m / (row.distance_m / 1000)
if valid(row.elevation_m)
else None,
)
for key in ("gap", "pace_variability", "heart_rate_drift", "sensor_coverage"):
row.values[key] = unit.get(key)
row.analysis_summary = row.values
row.previous = None
for name, tree in trees:
row.values[name] = evaluate(tree, row.values)
rows.append(row)
if len(rows) > 100000:
raise QueryError(
"This expands over 100,000 recording units. Use a shorter date range."
)
return rows
+37
View File
@@ -0,0 +1,37 @@
<section class="panel analysis-workspace">
<div class="section-head"><div><span class="eyebrow">ACCOUNT LIBRARY</span><h2>Investigations</h2></div><button type="button" class="button secondary" id="new-analysis">New analysis</button></div>
<div id="analysis-library" class="analysis-library" aria-live="polite"><p class="field-help">Loading your saved analyses…</p></div>
</section>
<dialog id="save-analysis-dialog" class="analysis-dialog">
<form id="save-analysis-form">
<div class="studio-row"><h2 id="save-analysis-title">Save analysis</h2><button type="button" class="text-button" id="close-analysis-dialog" aria-label="Close">×</button></div>
<label>Name<input name="name" maxlength="100" required placeholder="HR at easy pace"></label>
<label>Investigation<input name="collection" maxlength="80" required value="My analyses" placeholder="Easy-run effort"></label>
<label>Notes<textarea name="notes" maxlength="4000" rows="3" placeholder="What are you investigating? What should you check next?"></textarea></label>
<div class="analysis-settings"><label>Dashboard display<select name="view"><option value="chart">Plot</option><option value="value">Summary values</option><option value="table">Grouped table</option></select></label><label>Widget width<select name="width"><option value="half">Half width</option><option value="full">Full width</option></select></label></div>
<label class="analysis-check"><input name="pinned" type="checkbox"> Pin to live dashboard</label>
<p id="save-analysis-error" class="studio-error" role="alert"></p>
<div class="studio-row bottom"><button type="button" class="button secondary" id="save-analysis-copy">Save as new</button><button class="button primary" type="submit" id="save-analysis-submit">Save analysis</button></div>
</form>
</dialog>
<details class="panel analysis-builder">
<summary>Build a comparable-effort investigation</summary>
<form id="matched-effort-form">
<div class="analysis-settings">
<label>Compare inputs<select name="source"><option value="runs">Whole runs</option><option value="segments">Steady moving segments</option></select></label>
<label>Pace from · min/km<input name="pace_low" value="5:20" pattern="[0-9]+:[0-5][0-9]" required></label>
<label>Pace to · min/km<input name="pace_high" value="5:40" pattern="[0-9]+:[0-5][0-9]" required></label>
<label>Max ascent per km · m/km<input name="hills" type="number" min="0" max="1000" value="10" required></label>
<label>Current window · weeks<input name="weeks" type="number" min="1" max="104" value="12" required></label>
<label>Earlier baseline · days<input name="baseline" type="number" min="7" max="732" value="90" required></label>
<label>Minimum usable inputs per week<input name="minimum" type="number" min="2" max="1000" value="3" required></label>
</div>
<p class="field-help">Segments also require ≤5% speed variation, ≥80% moving HR coverage and ≥4 minutes. Matching pace and hills narrows the cohort; conditions can still differ.</p>
<button class="button primary" type="submit">Explore matched heart rate</button>
</form>
</details>
<details class="panel analysis-preset-gallery">
<summary>Start with a question</summary>
<div class="analysis-presets">{% for preset in presets %}<a class="analysis-preset" href="{{ url_for('web.stats', query=preset.query) }}"><span class="eyebrow">{{ preset.category }}</span><h3>{{ preset.name }}</h3><p>{{ preset.description }}</p></a>{% endfor %}</div>
</details>
<script id="selected-analysis" type="application/json">{{ selected_analysis|tojson }}</script>
+18
View File
@@ -0,0 +1,18 @@
<section id="live-analyses" data-csrf="{{ csrf_token() }}" aria-label="Live analysis dashboard">
<div class="section-head"><div><span class="eyebrow">LIVE OBSERVATORY</span><h2>Your training signals</h2><p class="field-help">Account-saved analyses · refreshed on page load, focus and every five minutes</p></div><div class="query-actions"><button class="button secondary" type="button" id="refresh-analyses">Refresh</button><a class="button primary" href="{{ url_for('web.stats') }}">Explore a question</a></div></div>
<div id="live-analysis-grid" class="live-analysis-grid">
{% for analysis in analyses %}
<article class="panel live-analysis-widget {{ 'widget-full' if analysis.width == 'full' }}" data-id="{{ analysis.id }}" data-view="{{ analysis.view }}">
<div class="section-head"><div><span class="eyebrow">{{ analysis.collection }}</span><h3>{{ analysis.name }}</h3></div><a class="back-link" href="{{ url_for('web.stats', analysis=analysis.id) }}">Explore →</a></div>
<div class="live-widget-body"><p class="field-help">Loading live results…</p></div>
<div class="live-widget-insights"></div>
{% if analysis.notes %}<details class="widget-notes"><summary>Investigation notes</summary><p>{{ analysis.notes }}</p></details>{% endif %}
<div class="widget-actions"><button class="text-button" type="button" data-widget-action="up" aria-label="Move {{ analysis.name }} earlier">↑</button><button class="text-button" type="button" data-widget-action="down" aria-label="Move {{ analysis.name }} later">↓</button><button class="text-button" type="button" data-widget-action="width">{{ 'Half width' if analysis.width == 'full' else 'Full width' }}</button><label class="sr-only" for="widget-view-{{ analysis.id }}">Display {{ analysis.name }} as</label><select id="widget-view-{{ analysis.id }}" data-widget-action="view"><option value="chart" {{ 'selected' if analysis.view == 'chart' }}>Plot</option><option value="value" {{ 'selected' if analysis.view == 'value' }}>Values</option><option value="table" {{ 'selected' if analysis.view == 'table' }}>Table</option></select><button class="text-button" type="button" data-widget-action="unpin">Unpin</button></div>
<p class="widget-status field-help" role="status"></p>
</article>
{% else %}
<div class="panel live-empty"><h3>Keep watching a useful question</h3><p>Explore matched-pace heart rate, workload or consistency in Stats, then pin your analysis here.</p><a class="button secondary" href="{{ url_for('web.stats') }}">Open analysis studio</a></div>
{% endfor %}
</div>
<p id="live-analysis-status" class="field-help" role="status"></p>
</section>
+3 -2
View File
@@ -1,10 +1,11 @@
{% extends 'base.html' %}
{% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %}
{% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='analysis.css') }}"><link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% block content %}
{% if not personal %}<a class="back-link" href="{{ url_for('web.friends') }}">← Friends</a>{% endif %}
<div class="page-heading"><div><h1>{{ 'Your running' if personal else profile.display_name ~ '’s running' }}</h1><p>{{ 'Distance, effort, and your recent sessions.' if personal else 'Activities shared with friends.' }}</p></div>{% if not personal %}<span class="pill friends">Friends · Shared runs</span>{% endif %}</div>
{% if personal %}{% include '_live_analyses.html' %}{% endif %}
<section class="dashboard-route panel" aria-label="Latest running route">
<div class="dashboard-route-info"><span class="eyebrow">LATEST RUN / ROUTE REPLAY</span><h2>{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}</h2>
{% if mapped_run %}<p>{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km</p><a class="back-link" href="{{ url_for('web.activity',activity_id=mapped_run.id) }}">View run {{ icon('arrow') }}</a>{% else %}<p>Import a run with GPS to see your route in motion.</p>{% endif %}
@@ -26,4 +27,4 @@
{% endif %}
<section class="panel activities-panel" id="history"><div class="section-head"><div><span class="eyebrow">SESSION HISTORY</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ stats.count }} activities</span></div>{% include '_activities.html' %}</section>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script><script src="{{ url_for('static',filename='analysis-charts.js') }}"></script><script src="{{ url_for('static',filename='live-analyses.js') }}"></script>{% endblock %}
+21 -16
View File
@@ -1,17 +1,17 @@
{% extends 'base.html' %}
{% block title %}Stats studio · Stride{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='vendor/codemirror.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='vendor/show-hint.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='stats.css') }}">{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='analysis.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='vendor/codemirror.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='vendor/show-hint.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='stats.css') }}">{% endblock %}
{% block content %}
<div id="stats-studio" data-owner="{{ current_user.id }}" data-show-previous="{{ 'true' if results and results.show_previous else 'false' }}" data-query="{{ query_text }}" data-csrf="{{ csrf_token() }}" data-validate-url="{{ url_for('web.stats_validate') }}">
<div class="page-heading"><div><span class="eyebrow">YOUR RUNNING / STATS STUDIO</span><h1>Your runs. Your questions.</h1><p>Write a query, explore the pattern, and inspect 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>
<div id="stats-studio" data-owner="{{ current_user.id }}" data-source="{{ query.source }}" data-show-previous="{{ 'true' if results and results.show_previous else 'false' }}" data-query="{{ query_text }}" data-csrf="{{ csrf_token() }}" data-validate-url="{{ url_for('web.stats_validate') }}">
<div class="page-heading"><div><span class="eyebrow">YOUR RUNNING / STATS STUDIO</span><h1>{{ selected_analysis.name if selected_analysis else "Your runs. Your questions." }}</h1><p>Write a query, explore the pattern, and inspect the runs behind it.</p></div><div class="query-actions"><button type="button" class="button secondary" id="save-view">Save analysis</button><button type="button" class="button primary" id="pin-analysis">Pin live analysis</button></div></div>
{% include '_analysis_workspace.html' %}
<script id="query-language" type="application/json">{{ language|tojson }}</script>
<form method="get" id="explorer-form" class="panel question-panel" novalidate>
<div class="studio-row"><div><span class="eyebrow">RUNNING QUERY</span><span class="query-caption">An analysis language for your running history.</span></div><div class="query-actions"><button type="button" class="button secondary" id="format-query">Format</button><button type="button" class="button secondary" id="complete-query">Suggest <kbd>Ctrl Space</kbd></button></div></div>
<div id="query-editor"><label class="sr-only" for="query-input">Running stats query</label><textarea id="query-input" name="query" spellcheck="false" maxlength="4000" rows="9">{{ submitted_query if submitted_query else query_text }}</textarea></div>
<div class="studio-row query-state"><span id="query-validation" role="status">Checking query...</span><span class="query-shortcut"><kbd>Ctrl / Cmd Enter</kbd> to run</span></div>
<div id="query-diagnostics" aria-live="polite">{% if error %}<p class="studio-error" role="alert">{{ error }}</p>{% endif %}</div>
<div class="studio-row studio-bottom"><span class="field-help">Whole-run metrics · {{ current_user.timezone }}<br>Lower pace values mean faster running.</span><button class="button primary" id="run-query" type="submit">Run query {{ icon('arrow') }}</button></div>
<div class="studio-row studio-bottom"><span class="field-help">{{ 'Whole-run metrics' if query.source == 'runs' else 'Recorded laps' if query.source == 'laps' else 'Five-minute moving segments' }} · {{ current_user.timezone }}<br>Lower pace values mean faster running.</span><button class="button primary" id="run-query" type="submit">Run query {{ icon('arrow') }}</button></div>
<details class="query-help"><summary>Language reference &amp; examples</summary><div class="query-reference"><div><h3>Filter and compare</h3><pre>during last 6 months
where distance between 8 and 20 km
and pace &lt;= 5:30 /km
@@ -23,7 +23,13 @@ rank by mean(distance) desc
limit 10
trend rolling 4 buckets
let efficiency = speed / heart_rate
plot pace vs heart_rate</pre><p class="field-help">Functions: sum, mean, weighted_mean, median, min, max, count, population stddev, and p10/p25/p75/p90/p95/p99. take limits ranked runs before grouping; limit limits groups. Trends also accept cumulative, change and percent_change. Arithmetic uses km, hours and seconds/km. Missing inputs and zero divisors give missing values.</p></div><div><h3>Metrics · select up to six</h3><div class="metric-reference">{% for key, info in metrics.items() %}<button type="button" class="metric-insert" data-metric="{{ key }}" title="{{ info[2] }}"><code>{{ key }}</code><span>{{ info[1] }}</span></button>{% endfor %}</div><p class="field-help">duration accepts h, min or s; distance accepts km or m. Use has / missing to select recorded sensor coverage.</p></div></div></details>
plot pace vs heart_rate
color by elevation_per_km
size by distance
baseline previous 90 days
flag above baseline 1.5 sd min 3 runs sustained 2 buckets
let hr_14d = rolling_mean(heart_rate, 14 days)
summarize mean(heart_rate) as "Matched HR"</pre><p class="field-help">Sources: runs, laps, segments. Charts: auto, line, bar, area, histogram, box, heatmap, stacked, none. Use display overlay to combine series. Functions: sum, mean, weighted_mean, median, min, max, count, population stddev, and p10/p25/p75/p90/p95/p99. take limits ranked runs before grouping; limit limits groups. Trends also accept cumulative, change and percent_change. Arithmetic uses km, hours and seconds/km. Missing inputs and zero divisors give missing values.</p></div><div><h3>Metrics · select up to six</h3><div class="metric-reference">{% for key, info in metrics.items() %}<button type="button" class="metric-insert" data-metric="{{ key }}" title="{{ info[2] }}"><code>{{ key }}</code><span>{{ info[1] }}</span></button>{% endfor %}</div><p class="field-help">duration accepts h, min or s; distance accepts km or m. Use has / missing to select recorded sensor coverage.</p></div></div></details>
<details id="guided-controls" class="query-help"><summary>Guided controls</summary><p class="field-help">Adjust the basic clauses without removing additional metrics and filters.</p>
<div class="studio-sentence">Show <label class="sr-only" for="metric">Primary metric</label><select id="metric" name="metric">{% if query.metric not in metrics %}<option value="{{ query.metric }}" selected>{{ query.metric }}</option>{% endif %}{% 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">{% if query.secondary not in metrics and query.secondary != 'none' %}<option value="{{ query.secondary }}" selected>{{ query.secondary }}</option>{% endif %}<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>
{% if query.all_time or query.start or query.days or query.months %}Change the period to the last{% else %}from runs during the last{% endif %} <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">{% if query.group == 'bin' %}<option value="bin" selected disabled>{{ query.bin.field }} bins</option>{% endif %}{% for grouping in language.groups %}<option value="{{ grouping }}" {{ 'selected' if query.group == grouping }}>{{ grouping }}</option>{% endfor %}</select>.</div>
@@ -37,19 +43,18 @@ plot pace vs heart_rate</pre><p class="field-help">Functions: sum, mean, weighte
</div>
</details>
</form>
<div class="studio-presets" aria-label="Starting questions"><span class="subtle-label">START WITH</span><a href="{{ url_for('web.stats',query='runs\n during all time\n summarize gap_since_previous_run, distance\n by run\n sort by gap_since_previous_run desc\n take 10\n compare none') }}">Biggest run gaps</a><a href="{{ url_for('web.stats',query='runs\n during last 24 months\n summarize count(), median(pace), sum(distance)\n by weekday\n having count() >= 3 runs\n compare none') }}">Weekday patterns</a><a href="{{ url_for('web.stats',query='runs\n during last 12 weeks\n summarize distance, count()\n by week\n trend rolling 4 buckets\n compare none') }}">Rolling volume</a><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>
<div class="studio-row result-heading"><div><h2>{{ results.count }} matching {{ query.source }}</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.unit == '/km' 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>
{% if query.chart != 'none' %}<section class="panel"><div class="section-head"><div><span class="eyebrow">TRENDS / {{ query.group|upper }}</span><h2>{{ 'Your question over time' if query.group in ('day','week','month','year') else 'Your question by ' ~ query.group }}</h2></div><span class="subtle-label">Solid: current · Dashed: previous</span></div><div class="studio-charts">{% for series in results.chart.series %}<div class="studio-chart"><h3>{{ series.label }} <small>{{ series.unit }}</small></h3><div><canvas id="stats-chart-{{ loop.index0 }}" role="img" aria-label="{{ series.label }} grouped by {{ query.group }}"></canvas></div></div>{% endfor %}</div><p class="studio-chart-help">Select a current-period point to inspect its runs. {% if query.trend %}Trend: {{ query.trend }} · Summary cards and the grouped table show the untransformed values.{% else %}Missing data leaves a gap.{% endif %} Time comparisons align by elapsed days; categorical comparisons use the same categories.</p></section>{% endif %}
<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.unit == '/km' else card.unit }}{% endif %}</p>{% endif %}<p>{{ card.coverage }} of {{ results.count }} {{ query.source }} 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 analysis-results"><div class="section-head"><div><span class="eyebrow">EXPLORE / VISUALIZE</span><h2>Your question, visualized</h2></div><div class="query-actions"><label>Chart <select data-presentation="chart" aria-label="Chart type">{% for type in ('auto','line','bar','area','histogram','box','heatmap','stacked','none') %}<option value="{{ type }}" {{ 'selected' if query.chart == type }}>{{ type|capitalize }}</option>{% endfor %}</select></label><label>Series <select data-presentation="display" aria-label="Series layout"><option value="separate" {{ 'selected' if query.display == 'separate' }}>Separate</option><option value="overlay" {{ 'selected' if query.display == 'overlay' }}>Overlay</option></select></label></div></div><div id="analysis-plots" class="analysis-plots"></div><p class="studio-chart-help">Select a point, box or calendar day to inspect contributing runs. Distribution charts show individual run inputs. Solid: current; dashed: previous. {% if query.trend %}Trend: {{ query.trend }}; cards and tables retain untransformed values.{% endif %}</p></section>
<section id="analysis-insights" class="analysis-insights" aria-label="Baseline and deviations"></section>
{% endif %}
{% if results.chart.scatter %}<section class="panel studio-scatter"><div class="section-head"><div><span class="eyebrow">RELATIONSHIP / RUN AVERAGES</span><h2>{{ results.chart.scatter.x[0] }} vs {{ results.chart.scatter.y[0] }}</h2></div><span class="subtle-label">{% if results.chart.scatter.correlation is not none %}Pearson r = {{ '%.3f'|format(results.chart.scatter.correlation) }}{% else %}Correlation unavailable{% endif %}</span></div><div class="scatter-wrap"><canvas id="stats-scatter" aria-label="Run metric scatter plot" role="img"></canvas></div><p class="studio-chart-help">Each point is one run. Select a point to open that run. Correlation describes association; it does not establish cause.</p></section>{% endif %}
<section class="panel studio-groups"><div class="section-head"><div><span class="eyebrow">ANALYSIS / GROUPED RESULTS</span><h2>{{ query.group|capitalize }} breakdown</h2></div><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='groups') }}">Export CSV</a></div><div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>GROUP</th><th>RUNS</th>{% for card in results.cards %}<th>{{ card.label|upper }} · {{ card.unit }}</th>{% endfor %}</tr></thead><tbody>{% for group in results.groups %}<tr><td><button type="button" class="group-drill" data-bucket="{{ group.label }}">{{ group.label }}</button></td><td>{{ group.count }}</td>{% for value in group['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% endfor %}</tbody></table></div></section>
<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"><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='runs') }}">Export runs</a><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.evidence_columns %}<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>{% if results.show_previous %}<small class="previous-run">Previous: {% if row.previous_run %}<a href="{{ url_for('web.activity',activity_id=row.previous_run.id) }}">{{ row.previous_run.date }} · {{ row.previous_run.title }}</a>{% else %}No earlier recorded run{% endif %}</small>{% endif %}</td><td>{{ row.distance }} km</td>{% for value in row['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% else %}<tr><td colspan="{{ results.cards|length + 2 }}">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>
<section class="panel studio-groups"><div class="section-head"><div><span class="eyebrow">ANALYSIS / GROUPED RESULTS</span><h2>{{ query.group|capitalize }} breakdown</h2></div><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='groups') }}">Export CSV</a></div><div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>GROUP</th><th>{{ query.source|upper }}</th>{% for card in results.cards %}<th>{{ card.label|upper }} · {{ card.unit }}</th>{% endfor %}</tr></thead><tbody>{% for group in results.groups %}<tr><td><button type="button" class="group-drill" data-bucket="{{ group.label }}">{{ group.label }}</button></td><td>{{ group.count }}</td>{% for value in group['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% endfor %}</tbody></table></div></section>
<section class="panel studio-evidence" id="studio-evidence"><div class="section-head"><div><span class="eyebrow">BEHIND THE NUMBERS</span><h2 id="evidence-heading">Contributing {{ query.source }}</h2></div><div class="studio-row"><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='runs') }}">Export runs</a><label class="studio-period" for="evidence-period">Period <select id="evidence-period"><option value="">All results</option>{% for label in results.chart.labels + results.chart.drill_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>{{ query.source|upper }} / LOCAL DATE</th><th>DISTANCE</th>{% for card in results.evidence_columns %}<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>{% if results.show_previous %}<small class="previous-run">Previous: {% if row.previous_run %}<a href="{{ url_for('web.activity',activity_id=row.previous_run.id) }}">{{ row.previous_run.date }} · {{ row.previous_run.title }}</a>{% else %}No earlier recorded run{% endif %}</small>{% endif %}</td><td>{{ row.distance }} km</td>{% for value in row['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% else %}<tr><td colspan="{{ results.cards|length + 2 }}">No matching runs.</td></tr>{% endfor %}</tbody></table></div><div class="studio-pagination"><span>Page {{ results.page }} of {{ results.pages }} · up to 25 {{ query.source }} 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><script id="insights-data" type="application/json">{{ results.insights|tojson }}</script>
{% endif %}
<p class="analysis-semantics field-help">Gaps, rolling metrics and streaks use your complete history before filters. Arithmetic and filters apply per run; summaries apply per group. Contributing runs show the individual input values for each summary. Empty sums remain missing; count is zero. Group ranking/limits and having select the displayed cohort before chart trends.</p><div id="studio-status" role="status" class="field-help"></div>
<p class="analysis-semantics field-help">Sources: runs, recorded laps, or five-minute moving segments. Segments exclude recording gaps and pauses; partial segments remain queryable. Gaps, rolling metrics and streaks use complete run history before filters. Arithmetic and filters apply per run; summaries apply per group. Contributing runs show the individual input values for each summary. Empty sums remain missing; count is zero. Group ranking/limits and having select the displayed cohort before chart trends.</p><div id="studio-status" role="status" class="field-help"></div>
</div>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/codemirror.js') }}"></script><script src="{{ url_for('static',filename='vendor/show-hint.js') }}"></script><script src="{{ url_for('static',filename='stats-editor.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='stats.js') }}"></script>{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/codemirror.js') }}"></script><script src="{{ url_for('static',filename='vendor/show-hint.js') }}"></script><script src="{{ url_for('static',filename='stats-editor.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='analysis-charts.js') }}"></script><script src="{{ url_for('static',filename='stats.js') }}"></script><script src="{{ url_for('static',filename='analysis-library.js') }}"></script>{% endblock %}