Add saved analysis workspace and live dashboard statistics
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
})();
|
||||
@@ -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
@@ -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();
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 & 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 <= 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 %}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user