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
+28 -3
View File
@@ -76,9 +76,34 @@ runs
Gaps use complete owner-scoped history before filters and link to the previous
run. The explorer uses persisted run summaries without loading recording samples.
Cards show metric definitions and usable-run coverage. Select a chart point or
group row to inspect contributing runs. Save named views in this browser, or
bookmark the results URL; relative queries follow the calendar as runs are added.
Cards show metric definitions and usable-input coverage. Select a chart point,
box, calendar day or group row to inspect contributing inputs. Save named
analyses to your account, organize them into investigations with notes, duplicate
them to ask a related question, and pin plots, values or tables to your live
dashboard. The library and layout follow your account across browsers/devices
connected to the same Stride server. Relative queries follow the calendar.
Plots include line, bar, area, overlaid series, histograms, quartile boxes,
calendar heatmaps, named-cohort stacked bars, and scatter plots with metric-based
color/size. Download plots as PNG or export numeric results as CSV. Ready-made
questions cover matched-pace HR, workload, consistency, decoupling and more.
Matched-cohort baselines and persistent deviation rules expose their coverage,
reference range and contributing inputs. Flags describe statistical departures;
they are not an overtraining diagnosis.
The DSL can query `runs`, recorded `laps`, or persisted five-minute moving
`segments`, with recording speed variability, HR coverage and speed/HR
decoupling. New imports calculate these from full-resolution recordings. After
upgrading an existing installation, run:
```powershell
uv run --locked flask --app app db upgrade
uv run --locked flask --app app backfill-analysis
```
The backfill is resumable. Dashboard queries read compact summaries rather than
raw recordings; pinned analyses refresh on load, window focus and every five
minutes while the page is visible.
### Planning running routes
+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 %}
+149
View File
@@ -1,5 +1,154 @@
# Run analysis DSL
## Account library and live dashboard
Save an analysis with a name, investigation collection and notes. **Save as new**
or **Duplicate** creates an independent query to explore another question.
**Pin live analysis** adds it to your personal dashboard, where it can display a
plot, summary values or a grouped table. Width and order are saved to your
account. All queries and widget requests enforce owner scope, including shared
friend profiles. Dashboard widgets refresh on load/focus and every five minutes
while visible; each widget retains its own query date range. Relative periods
advance with the calendar. Results are recomputed rather than saved as snapshots.
## Matched effort, reference ranges and persistent deviations
```text
runs
during last 12 weeks
where pace between 5:20 and 5:40 /km
and elevation_per_km <= 10 m/km
and has heart_rate
summarize weighted_mean(heart_rate) as "Matched-pace HR"
by week
compare none
baseline previous 90 days
flag above baseline 1.5 sd min 3 runs sustained 2 buckets
chart line
```
The baseline is the stated number of local calendar days immediately **before
the query starts**, filtered with the same per-input conditions. It never uses
the current cohort. The center uses the selected summary function; its range is
center ± threshold × population standard deviation of individual matched
inputs. This is a descriptive range, not a confidence interval or a diagnostic
threshold. At least ten usable baseline inputs, and nonzero variation, are
required. Totals, counts and standard deviations do not receive baseline bands.
`above`, `below` and `outside` are supported. Flags require the minimum usable
inputs in **each** consecutive calendar bucket; missing, insufficient, in-range
or opposite-direction buckets reset the streak. Only the bucket completing the
streak and following qualifying buckets are marked. The grammar uses `runs` for
the minimum count even for laps/segments; the displayed coverage uses the actual
source. Segments from one run are correlated observations, so a high segment
count does not imply many independent sessions. Calendar grouping is required;
trend transforms and group removal/ranking cannot be combined with flags.
Terrain, conditions, warm-up and sensor differences can still affect matched
effort. Inspect the actual inputs and workload before interpreting a departure.
## Recording-level exploration
```text
segments
during last 12 weeks
where pace between 5:20 and 5:40 /km
and pace_variability <= 5 %
and sensor_coverage >= 80 %
and duration >= 4 min
summarize weighted_mean(heart_rate)
by week
compare none
chart line
```
`segments` expands persisted five-minute **moving-time** segments; the final
partial segment is retained, so filter duration when complete segments matter.
The segments accumulate usable moving intervals across pauses/gaps rather than
claiming five uninterrupted minutes. Intervals require increasing timestamps
with at most ten seconds between recordings, positive plausible movement
(0.5–15 m/s), and increasing distance. Missing sensor values are not interpolated.
HR is duration weighted. Ascent requires elevation on at least 80% of usable
moving intervals; otherwise it is missing. Ascent is the sum of positive raw
altitude increments and can be sensitive to noise. Segment dates are the parent
run's local start date; selecting one opens that run.
`laps` expands recorded lap distance, timer duration, HR and persisted GAP. Other
sensor/terrain metrics remain missing when the lap does not record them. Neither
source loads raw samples during a query. Their `count()` counts inputs; `by run`
gives each lap/segment its own row. Run-history metrics and custom rolling
calculations require `runs`.
Additional recording-derived metrics:
- `pace_variability`: duration-weighted **speed** coefficient of variation (%).
- `sensor_coverage`: usable moving time with HR / usable moving time (%), excluding gaps and pauses.
- `heart_rate_drift`: percent loss of speed/HR efficiency between moving-time halves. At least four moving minutes and ≥80% HR coverage in each half are required. Positive means lower efficiency later; warm-up and terrain are included.
- `gap`: modelled grade-adjusted pace in seconds/km, formatted as min/km.
New imports calculate these; older activities require `backfill-analysis`.
## Visualization and named series
`chart auto|line|bar|area|histogram|box|heatmap|stacked|none` controls presentation.
`display overlay` combines series with an axis per unit; `display separate` is
the default. Summary labels use JSON double-quoted strings:
```text
summarize sum(distance) as "Weekly distance", count() as "Sessions"
reference heart_rate = 150 bpm
plot pace vs heart_rate
color by elevation_per_km
size by distance
```
References require the selected raw metric and compatible units. Scatter color
and size require a scatter plot; missing encoding values use midpoint styling.
Correlation uses paired per-input values, not grouped summaries.
Histograms bin **individual input values** using up to 30 equal-width bins;
the final bin includes its upper endpoint. Boxes show min, p25, median, p75 and
max per group, with no inferred outlier rule. Distribution charts show current
inputs, while comparison cards retain the previous cohort. Calendar heatmaps
require `by day` and retain every day. Missing data differs from a zero count.
Distribution/heatmap overlays are not supported. PNG download exports the plot;
CSV exports the raw underlying grouped or per-input values.
## Named training cohorts
```text
runs
during last 12 weeks
summarize sum(distance)
by week
compare none
cohort "Lower HR" where heart_rate <= 145 bpm
cohort "Higher HR" where heart_rate > 145 bpm
chart stacked
```
Up to four named numeric conditions partition inputs in declaration order: the
**first matching cohort wins**, avoiding double counting. Unmatched/missing
inputs appear in Other / missing. Stacking requires one additive summary
(sum/count, distance, duration, elevation or calories), with no comparison,
baseline or trend. Thresholds are user-selected; names do not infer physiological zones.
## Custom rolling calculations and yearly comparisons
```text
let hr_14d = rolling_mean(heart_rate, 14 days)
let km_10d = rolling_sum(distance, 10 days)
let hr_count = rolling_count(heart_rate, 14 days)
compare previous year
```
Rolling functions use complete owner-scoped run history before filters, include
the current run, and exclude the exact lower elapsed-time boundary. They accept
1–365 days and an earlier metric/calculation. Means are unweighted, sums exclude
missing inputs, and counts count usable inputs. Calendar-bucket rolling trends
remain separate operations. `compare previous year` uses the same local-date
interval one year earlier, clamping leap-day boundaries; time charts align by
elapsed days from each period's start.
Write queries in **Stats**, use **Ctrl+Space** for completion and **Ctrl/Cmd+Enter**
to execute. **Format** produces a canonical query. Live validation uses the same
parser as execution. Comments occupy their own lines starting with `#`.
@@ -0,0 +1,32 @@
"""Account analysis library and live dashboard layout."""
import sqlalchemy as sa
from alembic import op
revision = "f31a0c9286bd"
down_revision = "a219de4f805b"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"saved_analysis",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("collection", sa.String(80), nullable=False),
sa.Column("notes", sa.Text(), nullable=False),
sa.Column("query", sa.Text(), nullable=False),
sa.Column("pinned", sa.Boolean(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("width", sa.String(12), nullable=False),
sa.Column("view", sa.String(12), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_saved_analysis_owner_id", "saved_analysis", ["owner_id"])
def downgrade():
op.drop_table("saved_analysis")
+41 -11
View File
@@ -12,7 +12,7 @@ from waitress import serve
from app import create_app
from app.display import persist_analysis
from app.extensions import db
from app.models import Activity, User
from app.models import Activity, SavedAnalysis, User
from app.records import persist_efforts
app = create_app(
@@ -33,14 +33,20 @@ with app.app_context():
)
db.session.add(user)
db.session.flush()
for i in range(5):
analysis_preview = "--analysis" in sys.argv
for i in range(70 if analysis_preview else 5):
effort_hr = 157 + i % 3 if i < 8 else 141 + i % 5
samples = [
{
"point": [51.507 + 0.007 * math.sin(n / 70), -0.14 + n / 25000],
"distance": n * 20,
"distance": n * (15 if analysis_preview else 20),
"time": n * 5,
"speed": 2.8 + 0.3 * math.sin(n / 20),
"heart_rate": 140 + 12 * math.sin(n / 30),
"speed": 3 + 0.02 * math.sin(n / 20)
if analysis_preview
else 2.8 + 0.3 * math.sin(n / 20),
"heart_rate": effort_hr + math.sin(n / 30)
if analysis_preview
else 140 + 12 * math.sin(n / 30),
"elevation": 10 + n / 30,
}
for n in range(300)
@@ -55,12 +61,12 @@ with app.app_context():
"Easy Sunday",
"Tempo session",
"Evening loop",
][i],
started_at=datetime.now(UTC) - timedelta(days=i * 5),
distance_m=6000 + i * 700,
duration_s=2100 + i * 200,
elevation_m=38 + i * 7,
avg_hr=145,
][i % 5],
started_at=datetime.now(UTC) - timedelta(days=i * (2 if analysis_preview else 5)),
distance_m=6000 if analysis_preview else 6000 + i * 700,
duration_s=1980 if analysis_preview else 2100 + i * 200,
elevation_m=30 if analysis_preview else 38 + i * 7,
avg_hr=effort_hr if analysis_preview else 145,
sport="running",
visibility="friends",
samples=samples,
@@ -71,6 +77,30 @@ with app.app_context():
if isinstance(row, Activity):
persist_efforts(row)
persist_analysis(row)
if analysis_preview:
from app.stats_presets import PRESETS
names = [
"HR at comparable pace",
"Workload context",
"HR in steady segments",
"Easy and harder volume",
]
for position, name in enumerate(names):
preset = next(preset for preset in PRESETS if preset["name"] == name)
db.session.add(
SavedAnalysis(
owner_id=user.id,
name=name,
collection="Effort investigation",
query=preset["query"],
pinned=True,
position=position,
width="half",
view="chart",
notes="Synthetic preview data.",
)
)
db.session.commit()
# Test-only auto-login, isolated to this ephemeral process/database.
+333
View File
@@ -0,0 +1,333 @@
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import event
from test_app import login
from app.display import persist_analysis
from app.effort_segments import analyze
from app.extensions import db
from app.models import Activity
from app.stats import explore, parse_query, query_text
from app.stats_dsl import QueryError
from app.stats_presets import PRESETS
pytest_plugins = ("test_app",)
def parsed(text):
return parse_query({"query": text})
def activity(key, instant, hr=140, owner=1, samples=None):
return Activity(
owner_id=owner,
source_key=key,
title=key,
sport="running",
started_at=instant,
distance_m=10000,
duration_s=3300,
elevation_m=30,
avg_hr=hr,
samples=samples or [],
)
@pytest.mark.parametrize("preset", PRESETS, ids=[p["name"] for p in PRESETS])
def test_presets_roundtrip_and_execute(app, preset):
q = parsed(preset["query"])
assert parsed(query_text(q)) == q
with app.app_context():
result = explore(1, "Australia/Sydney", q)
assert result["source"] == q["source"]
client = app.test_client()
login(client)
assert client.get("/stats", query_string={"query": preset["query"]}).status_code == 200
def test_library_crud_pin_privacy_and_live_results(app):
client = app.test_client()
assert client.get("/stats/analyses").status_code == 302
login(client)
source = "runs\nduring all time\nsummarize distance\nby month\ncompare none"
response = client.post(
"/stats/analyses",
json={
"name": "Volume",
"query": source,
"pinned": True,
"collection": "Training",
"notes": "Check consistency",
},
)
assert response.status_code == 201
row = response.json["analysis"]
assert row["pinned"] and row["collection"] == "Training"
analysis_id = row["id"]
assert client.get("/stats/analyses").json["analyses"][0]["id"] == analysis_id
assert client.get(f"/stats?analysis={analysis_id}").status_code == 200
assert b"Volume" in client.get("/dashboard").data
result = client.get(f"/stats/analyses/{analysis_id}/results")
assert result.json["results"]["count"] == 1
assert "no-store" in result.headers["Cache-Control"]
with app.app_context():
db.session.add(activity("new", datetime(2026, 9, 29, tzinfo=UTC)))
db.session.commit()
assert client.get(f"/stats/analyses/{analysis_id}/results").json["results"]["count"] == 2
other = app.test_client()
login(other, "other")
assert other.get("/stats/analyses").json["analyses"] == []
for method, path in (
("get", f"/stats?analysis={analysis_id}"),
("get", f"/stats/analyses/{analysis_id}/results"),
("patch", f"/stats/analyses/{analysis_id}"),
("delete", f"/stats/analyses/{analysis_id}"),
):
assert getattr(other, method)(path, json={"name": "Stolen"}).status_code == 404
assert other.post("/stats/analyses/order", json={"ids": [analysis_id]}).status_code == 400
assert (
client.patch(
f"/stats/analyses/{analysis_id}", json={"width": "full", "view": "value"}
).json["analysis"]["width"]
== "full"
)
assert (
client.post("/stats/analyses/order", json={"ids": [analysis_id, analysis_id]}).status_code
== 400
)
assert client.post("/stats/analyses/order", json={"ids": [analysis_id]}).status_code == 200
assert client.delete(f"/stats/analyses/{analysis_id}").status_code == 204
assert client.get(f"/stats/analyses/{analysis_id}/results").status_code == 404
@pytest.mark.parametrize(
"payload",
[
None,
[],
{"name": ""},
{"name": "x", "query": "runs"},
{"name": "x", "query": "runs\nsummarize pace", "pinned": "yes"},
{"name": "x", "query": "runs\nsummarize pace", "width": []},
],
)
def test_library_rejects_invalid_payloads(app, payload):
client = app.test_client()
login(client)
assert client.post("/stats/analyses", json=payload).status_code == 400
def test_library_mutations_require_csrf(app):
client = app.test_client()
login(client)
app.config["WTF_CSRF_ENABLED"] = True
assert (
client.post(
"/stats/analyses", json={"name": "x", "query": "runs\nsummarize pace"}
).status_code
== 400
)
assert client.post("/stats/analyses/order", json={"ids": []}).status_code == 400
def test_named_summary_with_commas_quotes_and_reference_roundtrip():
q = parsed('runs\nsummarize weighted_mean (heart_rate) as "HR, \\"easy\\""')
assert q["labels"] == {"weighted_mean(heart_rate)": 'HR, "easy"'}
assert parsed(query_text(q)) == q
ref = parsed("runs\nsummarize heart_rate\nreference heart_rate = 150 bpm")
assert ref["reference"]["value"] == 150
assert parsed(query_text(ref)) == ref
def test_histogram_drilldown_and_quartiles_cover_exact_inputs(app):
with app.app_context():
db.session.add_all(
[
activity("ten", datetime(2026, 9, 29, tzinfo=UTC)),
activity("ten-more", datetime(2026, 9, 28, tzinfo=UTC)),
]
)
db.session.commit()
q = parsed(
"runs\nduring 2026-09-01 to 2026-09-30\nsummarize distance\nby all\ncompare none\nchart histogram"
)
result = explore(1, "Australia/Sydney", q)
histogram = result["chart"]["series"][0]["histogram"]
ids = []
for bucket, count in zip(histogram["buckets"], histogram["counts"], strict=True):
selected = explore(1, "Australia/Sydney", q, selected_bucket=bucket)
assert selected["evidence_count"] == count
ids.extend(row["id"] for row in selected["rows"])
assert len(ids) == len(set(ids)) == 3
q["chart"] = "box"
box = explore(1, "Australia/Sydney", q)["chart"]["series"][0]["boxes"][0]
assert box == {"low": 5, "q1": 7.5, "median": 10, "q3": 10, "high": 10, "count": 3}
def test_lap_source_does_not_inherit_parent_sensor_or_terrain(app):
with app.app_context():
q = parsed(
"laps\nduring 2026-09-30 to 2026-09-30\nsummarize pace, heart_rate, elevation\nby run\ncompare none"
)
result = explore(1, "Australia/Sydney", q)
assert result["count"] == 1
assert result["rows"][0]["raw_values"] == [360, 140, None]
assert result["cards"][2]["coverage"] == 0
def test_matched_baseline_flags_reset_at_missing_bucket(app):
with app.app_context():
start = datetime(2026, 9, 7, tzinfo=UTC)
rows = [
activity(f"base-{i}", start - timedelta(days=20 - i), 139 + i % 3) for i in range(12)
]
# An unmatched fast run and another owner's run cannot affect the baseline.
fast = activity("fast", start - timedelta(days=4), 200)
fast.duration_s = 2400
rows.extend([fast, activity("other-owner", start - timedelta(days=3), 230, owner=2)])
for week in (0, 1, 3, 4):
rows.extend(
activity(f"high-{week}-{i}", start + timedelta(days=week * 7 + i), 160)
for i in range(3)
)
db.session.add_all(rows)
db.session.commit()
q = parsed(
"runs\nduring 2026-09-07 to 2026-10-11\nwhere pace between 5:20 and 5:40 /km\nsummarize weighted_mean(heart_rate)\nby week\ncompare none\nbaseline previous 30 days\nflag above baseline 1.5 sd min 3 runs sustained 2 buckets"
)
result = explore(1, "Australia/Sydney", q)
assert result["insights"][0]["count"] == 12
assert result["insights"][0]["baseline"] == 140
assert result["chart"]["series"][0]["baseline"]["flags"] == [
False,
True,
False,
False,
True,
]
assert len(result["insights"][0]["flagged"]) == 2
def test_rolling_window_uses_history_before_filters(app):
with app.app_context():
db.session.add_all(
[
activity("before", datetime(2026, 9, 1, tzinfo=UTC), 120),
activity("target", datetime(2026, 9, 8, tzinfo=UTC), 160),
]
)
db.session.commit()
q = parsed(
"runs\nduring 2026-09-08 to 2026-09-08\nlet hr_14 = rolling_mean(heart_rate, 14 days)\nwhere heart_rate >= 150 bpm\nsummarize hr_14\nby run\ncompare none"
)
assert explore(1, "Australia/Sydney", q)["rows"][0]["raw_values"] == [140]
assert parsed(query_text(q)) == q
def test_cohorts_partition_without_double_counting(app):
with app.app_context():
db.session.add(activity("hard", datetime(2026, 9, 29, tzinfo=UTC), 160))
db.session.commit()
q = parsed(
'runs\nduring 2026-09-01 to 2026-09-30\nsummarize sum(distance)\nby all\ncompare none\ncohort "Easy" where heart_rate <= 145 bpm\ncohort "All HR" where heart_rate >= 100 bpm\nchart stacked'
)
assert parsed(query_text(q)) == q
cohorts = explore(1, "Australia/Sydney", q)["chart"]["cohorts"]
assert [c["values"] for c in cohorts] == [[5], [10], [None]]
def recording(hr_first=140, hr_second=160):
return [
{
"time": i,
"distance": i * 3,
"speed": 3,
"heart_rate": hr_first if i <= 300 else hr_second,
"elevation": 10,
}
for i in range(601)
]
def test_segments_and_drift_full_resolution():
effort, segments = analyze(recording())
assert len(segments) == 2
assert [s["duration"] for s in segments] == [300, 300]
assert [s["heart_rate"] for s in segments] == [140, 160]
assert effort["pace_variability"] == pytest.approx(0)
assert effort["heart_rate_drift"] == pytest.approx(12.5)
assert effort["sensor_coverage"] == 100
missing = recording()
for row in missing[200:]:
row["heart_rate"] = None
row.pop("elevation")
summary, _ = analyze(missing)
assert summary["heart_rate_drift"] is None
assert summary["elevation"] is None
assert summary["sensor_coverage"] < 40
reset = [{"time": 0, "distance": 10}, {"time": 11, "distance": 50}, {"time": 12, "distance": 0}]
assert analyze(reset)[1] == []
def test_segment_queries_use_compact_owner_scoped_data(app):
with app.app_context():
run = activity("recorded", datetime(2026, 9, 29, tzinfo=UTC), samples=recording())
other = activity(
"secret", datetime(2026, 9, 29, tzinfo=UTC), owner=2, samples=recording(200, 220)
)
db.session.add_all([run, other])
persist_analysis(run)
persist_analysis(other)
db.session.commit()
queries = []
def capture(_conn, _cursor, statement, _params, _context, _many):
queries.append(statement)
event.listen(db.engine, "before_cursor_execute", capture)
try:
q = parsed(
"segments\nduring 2026-09-29 to 2026-09-29\nwhere pace_variability <= 5 %\nand sensor_coverage >= 80 %\nsummarize heart_rate, pace\nby run\ncompare none"
)
result = explore(1, "Australia/Sydney", q)
finally:
event.remove(db.engine, "before_cursor_execute", capture)
assert result["count"] == 2
assert {row["id"] for row in result["rows"]} == {run.id}
assert {row["raw_values"][0] for row in result["rows"]} == {140, 160}
assert all("activity.samples" not in query for query in queries)
assert parsed(query_text(q)) == q
def test_previous_year_comparison(app):
with app.app_context():
db.session.add(activity("prior-year", datetime(2025, 9, 30, tzinfo=UTC), 150))
db.session.commit()
q = parsed(
"runs\nduring 2026-09-01 to 2026-09-30\nsummarize heart_rate\nby month\ncompare previous year"
)
assert parsed(query_text(q)) == q
result = explore(1, "Australia/Sydney", q)
assert result["prior_count"] == 1
assert result["cards"][0]["previous"] == "150.0"
assert result["previous_end"].isoformat() == "2025-09-30"
@pytest.mark.parametrize(
"source",
[
"runs\nsummarize pace\nflag above baseline 2 sd min 3 runs sustained 2 buckets",
"runs\nsummarize pace\nbaseline previous 1 days",
"runs\nsummarize pace\nby weekday\nbaseline previous 90 days",
"runs\nsummarize pace\nchart heatmap",
"runs\nsummarize pace\nchart stacked",
"runs\nsummarize pace\ncolor by distance",
"runs\nsummarize pace\nreference heart_rate = 150 bpm",
"segments\nsummarize rolling_7d_distance",
"runs\nlet bad = rolling_mean(password, 10 days)\nsummarize bad",
],
)
def test_invalid_analysis_features(source):
with pytest.raises(QueryError):
parsed(source)
+3 -1
View File
@@ -389,7 +389,9 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app):
row = db.session.get(Activity, row_id)
assert row.efforts_version == EFFORTS_VERSION
assert row.best_efforts[0]["seconds"] == 300
assert row.analysis_version == 1
from app.display import ANALYSIS_VERSION
assert row.analysis_version == ANALYSIS_VERSION
assert row.analysis_summary["summary"]["samples"] == 2
assert activity_from_fit(1, "test:new", [doc]) is None
doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z"
+6 -1
View File
@@ -38,7 +38,12 @@ def test_persisted_summaries_equal_full_resolution_and_raw_data_is_untouched():
expected = analyse(row)
persist_analysis(row)
assert row.analysis_version == ANALYSIS_VERSION
assert row.analysis_summary == {key: expected[key] for key in ("summary", "terrain", "laps")}
for key in ("terrain", "laps"):
assert row.analysis_summary[key] == expected[key]
for key, value in expected["summary"].items():
assert row.analysis_summary["summary"][key] == value
assert row.analysis_summary["segments"]
assert "heart_rate_drift" in row.analysis_summary["summary"]
assert row.samples == original
assert len(row.display_data["chart"]) == CHART_LIMIT
assert row.display_data["chart"][0]["distance"] == 0