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
|
Gaps use complete owner-scoped history before filters and link to the previous
|
||||||
run. The explorer uses persisted run summaries without loading recording samples.
|
run. The explorer uses persisted run summaries without loading recording samples.
|
||||||
Cards show metric definitions and usable-run coverage. Select a chart point or
|
Cards show metric definitions and usable-input coverage. Select a chart point,
|
||||||
group row to inspect contributing runs. Save named views in this browser, or
|
box, calendar day or group row to inspect contributing inputs. Save named
|
||||||
bookmark the results URL; relative queries follow the calendar as runs are added.
|
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
|
### Planning running routes
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ def create_app(config=None):
|
|||||||
from .routes import web
|
from .routes import web
|
||||||
|
|
||||||
app.register_blueprint(web)
|
app.register_blueprint(web)
|
||||||
|
from .analysis_library import library
|
||||||
|
|
||||||
|
app.register_blueprint(library)
|
||||||
from .planner import planner
|
from .planner import planner
|
||||||
|
|
||||||
app.register_blueprint(planner)
|
app.register_blueprint(planner)
|
||||||
|
|||||||
+113
-13
@@ -71,7 +71,7 @@ def formatted(number, key, q):
|
|||||||
return "—"
|
return "—"
|
||||||
spec = q["aggregates"].get(key)
|
spec = q["aggregates"].get(key)
|
||||||
raw = spec["field"] if spec and spec["function"] not in {"count", "stddev", "sum"} else 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")
|
return format_value(number, "pace")
|
||||||
if key == "count" or (spec and spec["function"] == "count"):
|
if key == "count" or (spec and spec["function"] == "count"):
|
||||||
return f"{number:,.0f}"
|
return f"{number:,.0f}"
|
||||||
@@ -130,9 +130,12 @@ def ordered(rows, ordering):
|
|||||||
def prepare(raw, zone, q, catalog):
|
def prepare(raw, zone, q, catalog):
|
||||||
rows, windows, totals = [], {7: deque(), 28: deque()}, {7: 0.0, 28: 0.0}
|
rows, windows, totals = [], {7: deque(), 28: deque()}, {7: 0.0, 28: 0.0}
|
||||||
previous, last_day, streak = None, None, 0
|
previous, last_day, streak = None, None, 0
|
||||||
trees = [
|
trees = {
|
||||||
(spec["name"], expression_tree(spec["expression"], {**catalog})[0]) for spec in q["derived"]
|
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:
|
for record in raw:
|
||||||
row = SimpleNamespace(**record._mapping)
|
row = SimpleNamespace(**record._mapping)
|
||||||
row.instant = (
|
row.instant = (
|
||||||
@@ -150,6 +153,7 @@ def prepare(raw, zone, q, catalog):
|
|||||||
if row.distance_m > 0
|
if row.distance_m > 0
|
||||||
else None,
|
else None,
|
||||||
max_heart_rate=(row.analysis_summary or {}).get("max_hr")
|
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"),
|
or (row.metrics or {}).get("max_heart_rate"),
|
||||||
calories=(row.metrics or {}).get("total_calories"),
|
calories=(row.metrics or {}).get("total_calories"),
|
||||||
training_effect=(row.metrics or {}).get("total_training_effect"),
|
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
|
streak = streak + 1 if last_day and (row.day - last_day).days == 1 else 1
|
||||||
last_day = row.day
|
last_day = row.day
|
||||||
row.values["run_day_streak"] = streak
|
row.values["run_day_streak"] = streak
|
||||||
for name, tree in trees:
|
for spec in q["derived"]:
|
||||||
row.values[name] = evaluate(tree, row.values)
|
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)
|
rows.append(row)
|
||||||
previous = row
|
previous = row
|
||||||
return rows
|
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):
|
def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, raw_group=False):
|
||||||
zone, catalog = ZoneInfo(timezone), catalog_for(q)
|
zone, catalog = ZoneInfo(timezone), catalog_for(q)
|
||||||
start, end, prior_start = period_bounds(q, timezone)
|
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)
|
boundary = lambda day: datetime.combine(day, datetime.min.time(), zone).astimezone(UTC)
|
||||||
# Temporal columns use every earlier run, independently of query filters.
|
# Temporal columns use every earlier run, independently of query filters.
|
||||||
raw = db.session.execute(
|
statement = (
|
||||||
db.select(
|
db.select(
|
||||||
Activity.id,
|
Activity.id,
|
||||||
Activity.title,
|
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),
|
Activity.started_at < boundary(end),
|
||||||
)
|
)
|
||||||
.order_by(Activity.started_at, Activity.id)
|
.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)
|
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:
|
if q["all_time"] and history:
|
||||||
start = history[0].day
|
start = history[0].day
|
||||||
prior_start = start
|
prior_start = start
|
||||||
@@ -242,7 +277,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
|||||||
[
|
[
|
||||||
row
|
row
|
||||||
for row in history
|
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"],
|
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"
|
return f"{token:02d}:00"
|
||||||
if group == "run":
|
if group == "run":
|
||||||
row = next(row for row in current if row.id == token)
|
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":
|
if group == "bin":
|
||||||
unit = catalog[width["field"]][1]
|
unit = catalog[width["field"]][1]
|
||||||
return f"{token * width['width']:g}–{(token + 1) * width['width']:g} {unit}"
|
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]}
|
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]
|
prior = [row for row in prior if row.id in kept_prior]
|
||||||
descriptors = {key: describe(key, catalog, q["aggregates"]) for key in keys}
|
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 = []
|
cards = []
|
||||||
for key in keys:
|
for key in keys:
|
||||||
number, coverage = summarize(current, key, q)
|
number, coverage = summarize(current, key, q)
|
||||||
before, _ = summarize(prior, key, q)
|
before, _ = summarize(prior, key, q)
|
||||||
title, unit, definition = descriptors[key]
|
title, unit, definition = descriptors[key]
|
||||||
|
if unit == "runs":
|
||||||
|
unit = q["source"]
|
||||||
cards.append(
|
cards.append(
|
||||||
{
|
{
|
||||||
"key": key,
|
"key": key,
|
||||||
@@ -438,6 +480,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
title, unit, _ = descriptors[key]
|
title, unit, _ = descriptors[key]
|
||||||
|
if unit == "runs":
|
||||||
|
unit = q["source"]
|
||||||
pace_axis = unit == "/km"
|
pace_axis = unit == "/km"
|
||||||
if q["trend"] == "percent_change":
|
if q["trend"] == "percent_change":
|
||||||
unit, pace_axis = "%", False
|
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"],
|
"type": q["chart"],
|
||||||
"group": group,
|
"group": group,
|
||||||
"trend": q["trend"],
|
"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
|
correlation = None
|
||||||
if q["plot"]:
|
if q["plot"]:
|
||||||
x, y = 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),
|
"x": value(row, x),
|
||||||
"y": value(row, y),
|
"y": value(row, y),
|
||||||
"id": row.id,
|
"id": getattr(row, "activity_id", row.id),
|
||||||
"label": row.title + " · " + row.local.strftime("%d %b %Y"),
|
"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
|
for row in rows
|
||||||
if valid(value(row, x)) and valid(value(row, y))
|
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,
|
"current": plotted,
|
||||||
"previous": points(prior) if q["compare"] else [],
|
"previous": points(prior) if q["compare"] else [],
|
||||||
"correlation": correlation,
|
"correlation": correlation,
|
||||||
|
"color": catalog[q["color"]] if q["color"] else None,
|
||||||
|
"size": catalog[q["size"]] if q["size"] else None,
|
||||||
}
|
}
|
||||||
evidence = current
|
evidence = current
|
||||||
if selected_bucket is not None:
|
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
|
for key in keys
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": getattr(row, "activity_id", row.id),
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
"date": row.local.strftime("%d %b %Y %H:%M"),
|
"date": row.local.strftime("%d %b %Y %H:%M"),
|
||||||
"distance": f"{row.distance_m / 1000:.2f}",
|
"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 {
|
return {
|
||||||
"cards": cards,
|
"cards": cards,
|
||||||
|
"source": q["source"],
|
||||||
"chart": chart,
|
"chart": chart,
|
||||||
|
"insights": insights,
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
"evidence_columns": [
|
"evidence_columns": [
|
||||||
{"key": field, "label": catalog[field][0], "unit": catalog[field][1]}
|
{"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,
|
"start": start,
|
||||||
"end": end - timedelta(days=1),
|
"end": end - timedelta(days=1),
|
||||||
"previous_start": prior_start,
|
"previous_start": prior_start,
|
||||||
"previous_end": start - timedelta(days=1),
|
"previous_end": prior_end - timedelta(days=1),
|
||||||
"page": page,
|
"page": page,
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
"evidence_count": len(evidence),
|
"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
|
from .analytics import analyse, numeric
|
||||||
|
|
||||||
ANALYSIS_VERSION = 1
|
ANALYSIS_VERSION = 2
|
||||||
CHART_LIMIT = 750
|
CHART_LIMIT = 750
|
||||||
ROUTE_TARGET = 1400
|
ROUTE_TARGET = 1400
|
||||||
CHART_KEYS = (
|
CHART_KEYS = (
|
||||||
@@ -104,8 +104,15 @@ def compact_routes(samples):
|
|||||||
|
|
||||||
|
|
||||||
def persist_analysis(activity):
|
def persist_analysis(activity):
|
||||||
|
from .effort_segments import analyze
|
||||||
|
|
||||||
analysis = analyse(activity)
|
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 = {key: analysis[key] for key in ("summary", "terrain", "laps")}
|
||||||
|
activity.analysis_summary["segments"] = segments
|
||||||
routes = compact_routes(analysis["samples"])
|
routes = compact_routes(analysis["samples"])
|
||||||
activity.route_geometry = [[sample["point"] for sample in segment] for segment in routes]
|
activity.route_geometry = [[sample["point"] for sample in segment] for segment in routes]
|
||||||
chart_indices = evenly_spaced(len(analysis["samples"]), CHART_LIMIT)
|
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):
|
class Friendship(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
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 .display import stored_analysis
|
||||||
from .extensions import db, limiter
|
from .extensions import db, limiter
|
||||||
from .history import activity_page, latest_mapped_run, overview, records_for
|
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 .services import activity_data, import_cache, import_upload, route_data
|
||||||
from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text
|
from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text
|
||||||
from .stats_analysis import EXTRA_UNITS, FUNCTIONS, GROUPS
|
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}),
|
record_book=records_for(current_user.id, {row.id for row in pagination.items}),
|
||||||
mapped_run=latest_mapped_run(filters),
|
mapped_run=latest_mapped_run(filters),
|
||||||
pace_text=pace_text,
|
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")
|
@web.get("/stats")
|
||||||
@login_required
|
@login_required
|
||||||
def stats():
|
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
|
error = None
|
||||||
results = None
|
results = None
|
||||||
try:
|
try:
|
||||||
query = parse_query(request.args)
|
query = parse_query(args)
|
||||||
page = int(request.args.get("page", "1"))
|
page = int(request.args.get("page", "1"))
|
||||||
results = explore(current_user.id, current_user.timezone, query, page)
|
results = explore(current_user.id, current_user.timezone, query, page)
|
||||||
except (ValueError, OverflowError) as exc:
|
except (ValueError, OverflowError) as exc:
|
||||||
@@ -218,7 +236,9 @@ def stats():
|
|||||||
metrics=METRICS,
|
metrics=METRICS,
|
||||||
results=results,
|
results=results,
|
||||||
error=error,
|
error=error,
|
||||||
submitted_query=request.args.get("query", ""),
|
submitted_query=args.get("query", ""),
|
||||||
|
selected_analysis=serialize(saved) if saved else None,
|
||||||
|
presets=PRESETS,
|
||||||
language={
|
language={
|
||||||
"functions": sorted(FUNCTIONS),
|
"functions": sorted(FUNCTIONS),
|
||||||
"groups": sorted(GROUPS),
|
"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 = [
|
const templates = [
|
||||||
['runs', 'Your recorded running activities'],
|
['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 12 weeks', 'Rolling period ending today'],
|
||||||
['during last 6 months', 'Calendar months'],
|
['during last 6 months', 'Calendar months'],
|
||||||
['during last 30 days', 'Rolling days'],
|
['during last 30 days', 'Rolling days'],
|
||||||
@@ -41,7 +45,14 @@
|
|||||||
['trend change', 'Difference from the preceding bucket'],
|
['trend change', 'Difference from the preceding bucket'],
|
||||||
['trend percent_change', 'Percentage change 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'],
|
['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'],
|
['by distance bins 5 km', 'Group runs into half-open distance ranges'],
|
||||||
['during 2026-07-01 to 2026-09-30', 'Inclusive local-date range'],
|
['during 2026-07-01 to 2026-09-30', 'Inclusive local-date range'],
|
||||||
['ending 2026-10-02', 'Optional end date for a rolling period'],
|
['ending 2026-10-02', 'Optional end date for a rolling period'],
|
||||||
@@ -71,7 +82,7 @@
|
|||||||
const word = stream.current();
|
const word = stream.current();
|
||||||
if (names.has(word) || availableMetrics().some(metric=>metric.name===word)) return 'variable-2';
|
if (names.has(word) || availableMetrics().some(metric=>metric.name===word)) return 'variable-2';
|
||||||
if (language.functions.includes(word)) return 'builtin';
|
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';
|
if (/^(day|days|week|weeks|month|months|km|m|bpm|spm|W|ms|h|min|s|date)$/.test(word)) return 'atom';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -89,7 +100,7 @@
|
|||||||
} else if (/^\s*rank by (?:\w+|\w+\(\w*\))\s+\w*$/.test(line)) {
|
} 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'}));
|
list=['asc','desc'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Group ranking order'}));
|
||||||
} else if (/^\s*chart\s+\w*$/.test(line)) {
|
} 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)) {
|
} else if (/^\s*trend\s+[\w ]*$/.test(line)) {
|
||||||
from={line:cursor.line,ch:line.indexOf('trend')+6};const prefix=line.slice(from.ch);
|
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'}));
|
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.
|
// Submit only the authoritative DSL. Helper controls never override it.
|
||||||
const url=new URL(form.action||location.pathname,location.origin);
|
const url=new URL(form.action||location.pathname,location.origin);
|
||||||
url.search=new URLSearchParams({query:text});
|
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);
|
location.assign(url);
|
||||||
});
|
});
|
||||||
document.getElementById('format-query').onclick=async()=>{
|
document.getElementById('format-query').onclick=async()=>{
|
||||||
|
|||||||
+11
-57
@@ -2,48 +2,22 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const root = document.getElementById('stats-studio');
|
const root = document.getElementById('stats-studio');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const form = document.getElementById('explorer-form');
|
|
||||||
const input = document.getElementById('query-input');
|
|
||||||
const status = document.getElementById('studio-status');
|
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');
|
const data = document.getElementById('stats-data');
|
||||||
if (!data || typeof Chart === 'undefined') return;
|
if (!data || typeof Chart === 'undefined') return;
|
||||||
const chartData = JSON.parse(data.textContent);
|
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 clear = document.getElementById('clear-selection');
|
||||||
const tbody = document.getElementById('evidence-body');
|
const tbody = document.getElementById('evidence-body');
|
||||||
|
let selectionScope = 'trend';
|
||||||
const original = [...tbody.children].map(row => row.cloneNode(true));
|
const original = [...tbody.children].map(row => row.cloneNode(true));
|
||||||
const pagination = root.querySelector('.studio-pagination');
|
const pagination = root.querySelector('.studio-pagination');
|
||||||
const period = document.getElementById('evidence-period');
|
const period = document.getElementById('evidence-period');
|
||||||
const originalPagination = [...pagination.children].map(node => node.cloneNode(true));
|
const originalPagination = [...pagination.children].map(node => node.cloneNode(true));
|
||||||
let pending = null;
|
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 = ''; };
|
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); else clear.click(); };
|
period.onchange = () => { if (period.value) loadBucket(period.value,1,selectionScope); else clear.click(); };
|
||||||
async function loadBucket(bucket, page = 1, scope = 'trend') {
|
async function loadBucket(bucket, page = 1, scope = 'trend') {
|
||||||
|
selectionScope=scope;
|
||||||
pending?.abort();
|
pending?.abort();
|
||||||
const controller = new AbortController(); pending = controller;
|
const controller = new AbortController(); pending = controller;
|
||||||
const url = new URL('/stats/runs', location.origin);
|
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); });
|
[`${row.distance} km`, ...row.values].forEach(text => { const cell = document.createElement('td'); cell.textContent = text; tr.append(cell); });
|
||||||
tbody.append(tr);
|
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;
|
period.value = bucket;
|
||||||
pagination.replaceChildren();
|
pagination.replaceChildren();
|
||||||
const label = document.createElement('span'); label.textContent = `Page ${result.page} of ${result.pages} · selected period`; pagination.append(label);
|
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.'; }
|
} 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'));
|
root.querySelectorAll('[data-bucket]').forEach(button=>button.onclick=()=>loadBucket(button.dataset.bucket,1,'group'));
|
||||||
chartData.series.forEach((series, index) => {
|
const plots=document.getElementById('analysis-plots');
|
||||||
const canvas = document.getElementById(`stats-chart-${index}`);
|
if(plots)StrideCharts.render(plots,chartData,bucket=>loadBucket(bucket));
|
||||||
if (!canvas) return;
|
const insightPanel=document.getElementById('analysis-insights');
|
||||||
new Chart(canvas, {
|
if(insightPanel)StrideCharts.insights(insightPanel,JSON.parse(document.getElementById('insights-data').textContent),bucket=>loadBucket(bucket,1,'group'));
|
||||||
type: chartData.type === 'auto' ? (['bin','weekday','hour','run','all'].includes(chartData.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line') : chartData.type,
|
const initialBucket=new URL(location.href).searchParams.get('bucket');
|
||||||
data: {labels: chartData.labels, datasets: [
|
if(initialBucket)loadBucket(initialBucket,1,new URL(location.href).searchParams.get('scope')||'trend');
|
||||||
{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}`);}}}});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+54
-13
@@ -61,6 +61,16 @@ DEFAULT = {
|
|||||||
"derived": [],
|
"derived": [],
|
||||||
"aggregates": {},
|
"aggregates": {},
|
||||||
"filter_groups": [],
|
"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"
|
period = f"during last {q['days']} days"
|
||||||
else:
|
else:
|
||||||
period = f"during last {q['weeks']} weeks"
|
period = f"during last {q['weeks']} weeks"
|
||||||
lines = ["runs", " " + period]
|
lines = [q["source"], " " + period]
|
||||||
for spec in q["derived"]:
|
for spec in q["derived"]:
|
||||||
lines.append(f" let {spec['name']} = {spec['expression']}")
|
lines.append(f" let {spec['name']} = {spec['expression']}")
|
||||||
if q["end"] and not q["start"]:
|
if q["end"] and not q["start"]:
|
||||||
@@ -166,7 +176,11 @@ def query_text(q):
|
|||||||
)
|
)
|
||||||
for condition in q["filters"]:
|
for condition in q["filters"]:
|
||||||
lines.append(clause(condition))
|
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(
|
lines.extend(
|
||||||
[
|
[
|
||||||
f" summarize {metrics}",
|
f" summarize {metrics}",
|
||||||
@@ -175,7 +189,14 @@ def query_text(q):
|
|||||||
if q["bin"]
|
if q["bin"]
|
||||||
else f" by {q['group']}"
|
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"]:
|
if q["sort"] != ["date", "desc"]:
|
||||||
@@ -188,7 +209,7 @@ def query_text(q):
|
|||||||
"runs"
|
"runs"
|
||||||
if spec and spec["function"] == "count"
|
if spec and spec["function"] == "count"
|
||||||
else "sec/km"
|
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]))
|
else next(iter(units[field]))
|
||||||
)
|
)
|
||||||
lines.append(f" having {key} {condition['op']} {number_text(condition['value'])} {unit}")
|
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]}")
|
lines.append(f" plot {q['plot'][0]} vs {q['plot'][1]}")
|
||||||
if q["chart"] != "auto":
|
if q["chart"] != "auto":
|
||||||
lines.append(f" chart {q['chart']}")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -254,16 +294,17 @@ def metric_value(row, key):
|
|||||||
if key == "heart_rate":
|
if key == "heart_rate":
|
||||||
return row.avg_hr
|
return row.avg_hr
|
||||||
if key in {"distance", "duration", "elevation"}:
|
if key in {"distance", "duration", "elevation"}:
|
||||||
return (
|
raw = getattr(
|
||||||
getattr(
|
row,
|
||||||
row,
|
{"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[key],
|
||||||
{"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[
|
|
||||||
key
|
|
||||||
],
|
|
||||||
)
|
|
||||||
/ {"distance": 1000, "duration": 3600, "elevation": 1}[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):
|
def aggregate(rows, key):
|
||||||
|
|||||||
+73
-6
@@ -25,6 +25,26 @@ FUNCTIONS = {
|
|||||||
}
|
}
|
||||||
GROUPS = {"day", "week", "month", "year", "weekday", "hour", "run", "all"}
|
GROUPS = {"day", "week", "month", "year", "weekday", "hour", "run", "all"}
|
||||||
EXTRA_METRICS = {
|
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."),
|
"count": ("Runs", "runs", "Number of runs."),
|
||||||
"speed": ("Speed", "km/h", "Recorded distance divided by timer duration."),
|
"speed": ("Speed", "km/h", "Recorded distance divided by timer duration."),
|
||||||
"gap_since_previous_run": (
|
"gap_since_previous_run": (
|
||||||
@@ -91,6 +111,10 @@ EXTRA_METRICS = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
EXTRA_UNITS = {
|
EXTRA_UNITS = {
|
||||||
|
"pace_variability": {"%": 1},
|
||||||
|
"heart_rate_drift": {"%": 1},
|
||||||
|
"sensor_coverage": {"%": 1},
|
||||||
|
"gap": {"/km": 1},
|
||||||
"count": {"runs": 1},
|
"count": {"runs": 1},
|
||||||
"speed": {"km/h": 1, "m/s": 3.6},
|
"speed": {"km/h": 1, "m/s": 3.6},
|
||||||
"gap_since_previous_run": {"days": 1, "h": 1 / 24, "min": 1 / 1440},
|
"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.
|
# Pace values inside arithmetic are seconds per km, not a formatted string.
|
||||||
return (
|
return (
|
||||||
"sec/km"
|
"sec/km"
|
||||||
if node.id == "pace"
|
if catalog[node.id][1] == "/km"
|
||||||
else "h"
|
else "h"
|
||||||
if node.id == "duration"
|
if node.id == "duration"
|
||||||
else catalog[node.id][1]
|
else catalog[node.id][1]
|
||||||
@@ -199,6 +223,9 @@ def evaluate(tree, values):
|
|||||||
def parse_analysis(text, metrics, defaults):
|
def parse_analysis(text, metrics, defaults):
|
||||||
if not isinstance(text, str) or len(text) > 4000:
|
if not isinstance(text, str) or len(text) > 4000:
|
||||||
raise QueryError("Keep the query under 4,000 characters.")
|
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}
|
catalog, units = dict(metrics), {**UNITS, **EXTRA_UNITS}
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
derived, aggregates, seen, locations = [], {}, set(), {}
|
derived, aggregates, seen, locations = [], {}, set(), {}
|
||||||
@@ -258,14 +285,26 @@ def parse_analysis(text, metrics, defaults):
|
|||||||
raise QueryError(
|
raise QueryError(
|
||||||
"Choose a new lowercase metric name; at most eight calculations.", number
|
"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] = (
|
catalog[name] = (
|
||||||
name.replace("_", " ").capitalize(),
|
name.replace("_", " ").capitalize(),
|
||||||
unit,
|
unit,
|
||||||
f"Calculated per run: {expression}. Missing inputs or division by zero give missing data.",
|
f"Calculated per run: {expression}. Missing inputs or division by zero give missing data.",
|
||||||
)
|
)
|
||||||
units[name] = {unit: 1}
|
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] = ""
|
lines[index] = ""
|
||||||
elif line == "during all time":
|
elif line == "during all time":
|
||||||
once("all-time period", number)
|
once("all-time period", number)
|
||||||
@@ -335,14 +374,14 @@ def parse_analysis(text, metrics, defaults):
|
|||||||
{"runs": 1}
|
{"runs": 1}
|
||||||
if function == "count"
|
if function == "count"
|
||||||
else {"sec/km": 1}
|
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]
|
else units[field]
|
||||||
)
|
)
|
||||||
unit = unit or next(iter(accepted))
|
unit = unit or next(iter(accepted))
|
||||||
if unit not in accepted:
|
if unit not in accepted:
|
||||||
raise QueryError(f"Use {' or '.join(accepted)} for {key}.", number)
|
raise QueryError(f"Use {' or '.join(accepted)} for {key}.", number)
|
||||||
try:
|
try:
|
||||||
if field == "pace" and unit == "/km" and ":" in raw:
|
if unit == "/km" and ":" in raw:
|
||||||
minutes, seconds = raw.split(":")
|
minutes, seconds = raw.split(":")
|
||||||
if not re.fullmatch(r"\d+:\d{2}", raw) or int(seconds) >= 60:
|
if not re.fullmatch(r"\d+:\d{2}", raw) or int(seconds) >= 60:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
@@ -394,6 +433,7 @@ def parse_analysis(text, metrics, defaults):
|
|||||||
)
|
)
|
||||||
q = parse_dsl("\n".join(lines), catalog, defaults, units=units)
|
q = parse_dsl("\n".join(lines), catalog, defaults, units=units)
|
||||||
q.update(options)
|
q.update(options)
|
||||||
|
q.update(features)
|
||||||
q["derived"], q["aggregates"] = derived, aggregates
|
q["derived"], q["aggregates"] = derived, aggregates
|
||||||
if group:
|
if group:
|
||||||
q["group"] = group
|
q["group"] = group
|
||||||
@@ -440,6 +480,33 @@ def parse_analysis(text, metrics, defaults):
|
|||||||
groups[-1].extend(batch)
|
groups[-1].extend(batch)
|
||||||
position += count
|
position += count
|
||||||
q["filter_groups"], q["filters"] = groups, []
|
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
|
return q
|
||||||
|
|
||||||
|
|
||||||
@@ -454,7 +521,7 @@ def describe(key, catalog, aggregates):
|
|||||||
else f"{function.replace('_', ' ').capitalize()} · {catalog[field][0].lower()}"
|
else f"{function.replace('_', ' ').capitalize()} · {catalog[field][0].lower()}"
|
||||||
)
|
)
|
||||||
unit = "runs" if function == "count" else catalog[field][1]
|
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"
|
unit = "sec/km"
|
||||||
definition = (
|
definition = (
|
||||||
(
|
(
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ def parse_dsl(text, metrics, defaults, units=None):
|
|||||||
def amount(key, raw, unit, line):
|
def amount(key, raw, unit, line):
|
||||||
if unit not in units[key]:
|
if unit not in units[key]:
|
||||||
raise QueryError(f"Use {' or '.join(units[key])} for {key}.", line)
|
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(":")
|
minutes, seconds = raw.split(":")
|
||||||
if not minutes.isdecimal() or not seconds.isdecimal() or int(seconds) >= 60:
|
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)
|
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' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %}
|
{% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %}
|
||||||
{% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% 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 %}
|
{% block content %}
|
||||||
{% if not personal %}<a class="back-link" href="{{ url_for('web.friends') }}">← Friends</a>{% endif %}
|
{% 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>
|
<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">
|
<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>
|
<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 %}
|
{% 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 %}
|
{% 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>
|
<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 %}
|
{% 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' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}Stats studio · Stride{% endblock %}
|
{% 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 %}
|
{% 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 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>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 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>
|
||||||
<div id="saved-views" class="studio-presets" aria-label="Saved views"></div>
|
{% include '_analysis_workspace.html' %}
|
||||||
<script id="query-language" type="application/json">{{ language|tojson }}</script>
|
<script id="query-language" type="application/json">{{ language|tojson }}</script>
|
||||||
<form method="get" id="explorer-form" class="panel question-panel" novalidate>
|
<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 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 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 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 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
|
<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
|
where distance between 8 and 20 km
|
||||||
and pace <= 5:30 /km
|
and pace <= 5:30 /km
|
||||||
@@ -23,7 +23,13 @@ rank by mean(distance) desc
|
|||||||
limit 10
|
limit 10
|
||||||
trend rolling 4 buckets
|
trend rolling 4 buckets
|
||||||
let efficiency = speed / heart_rate
|
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>
|
<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>
|
<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>
|
{% 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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</form>
|
</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 %}
|
{% 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 %}
|
{% 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>
|
<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>
|
||||||
{% 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 %}
|
<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 %}
|
{% 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>{{ 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-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" 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>
|
||||||
<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><script id="insights-data" type="application/json">{{ results.insights|tojson }}</script>
|
||||||
<script id="stats-data" type="application/json">{{ results.chart|tojson }}</script>
|
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% endblock %}
|
{% 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
|
# 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**
|
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
|
to execute. **Format** produces a canonical query. Live validation uses the same
|
||||||
parser as execution. Comments occupy their own lines starting with `#`.
|
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 import create_app
|
||||||
from app.display import persist_analysis
|
from app.display import persist_analysis
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import Activity, User
|
from app.models import Activity, SavedAnalysis, User
|
||||||
from app.records import persist_efforts
|
from app.records import persist_efforts
|
||||||
|
|
||||||
app = create_app(
|
app = create_app(
|
||||||
@@ -33,14 +33,20 @@ with app.app_context():
|
|||||||
)
|
)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.flush()
|
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 = [
|
samples = [
|
||||||
{
|
{
|
||||||
"point": [51.507 + 0.007 * math.sin(n / 70), -0.14 + n / 25000],
|
"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,
|
"time": n * 5,
|
||||||
"speed": 2.8 + 0.3 * math.sin(n / 20),
|
"speed": 3 + 0.02 * math.sin(n / 20)
|
||||||
"heart_rate": 140 + 12 * math.sin(n / 30),
|
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,
|
"elevation": 10 + n / 30,
|
||||||
}
|
}
|
||||||
for n in range(300)
|
for n in range(300)
|
||||||
@@ -55,12 +61,12 @@ with app.app_context():
|
|||||||
"Easy Sunday",
|
"Easy Sunday",
|
||||||
"Tempo session",
|
"Tempo session",
|
||||||
"Evening loop",
|
"Evening loop",
|
||||||
][i],
|
][i % 5],
|
||||||
started_at=datetime.now(UTC) - timedelta(days=i * 5),
|
started_at=datetime.now(UTC) - timedelta(days=i * (2 if analysis_preview else 5)),
|
||||||
distance_m=6000 + i * 700,
|
distance_m=6000 if analysis_preview else 6000 + i * 700,
|
||||||
duration_s=2100 + i * 200,
|
duration_s=1980 if analysis_preview else 2100 + i * 200,
|
||||||
elevation_m=38 + i * 7,
|
elevation_m=30 if analysis_preview else 38 + i * 7,
|
||||||
avg_hr=145,
|
avg_hr=effort_hr if analysis_preview else 145,
|
||||||
sport="running",
|
sport="running",
|
||||||
visibility="friends",
|
visibility="friends",
|
||||||
samples=samples,
|
samples=samples,
|
||||||
@@ -71,6 +77,30 @@ with app.app_context():
|
|||||||
if isinstance(row, Activity):
|
if isinstance(row, Activity):
|
||||||
persist_efforts(row)
|
persist_efforts(row)
|
||||||
persist_analysis(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()
|
db.session.commit()
|
||||||
|
|
||||||
# Test-only auto-login, isolated to this ephemeral process/database.
|
# 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)
|
row = db.session.get(Activity, row_id)
|
||||||
assert row.efforts_version == EFFORTS_VERSION
|
assert row.efforts_version == EFFORTS_VERSION
|
||||||
assert row.best_efforts[0]["seconds"] == 300
|
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 row.analysis_summary["summary"]["samples"] == 2
|
||||||
assert activity_from_fit(1, "test:new", [doc]) is None
|
assert activity_from_fit(1, "test:new", [doc]) is None
|
||||||
doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z"
|
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)
|
expected = analyse(row)
|
||||||
persist_analysis(row)
|
persist_analysis(row)
|
||||||
assert row.analysis_version == ANALYSIS_VERSION
|
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 row.samples == original
|
||||||
assert len(row.display_data["chart"]) == CHART_LIMIT
|
assert len(row.display_data["chart"]) == CHART_LIMIT
|
||||||
assert row.display_data["chart"][0]["distance"] == 0
|
assert row.display_data["chart"][0]["distance"] == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user