From e6e2f22479d7699b0a4b32348966452c4e47e76c Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sat, 3 Oct 2026 12:27:20 +1000 Subject: [PATCH] Add saved analysis workspace and live dashboard statistics --- README.md | 31 +- app/__init__.py | 3 + app/analysis_engine.py | 126 ++++++- app/analysis_library.py | 165 +++++++++ app/display.py | 9 +- app/effort_segments.py | 98 ++++++ app/models.py | 22 ++ app/routes.py | 26 +- app/static/analysis-charts.js | 91 +++++ app/static/analysis-library.js | 58 +++ app/static/analysis.css | 6 + app/static/live-analyses.js | 46 +++ app/static/stats-editor.js | 18 +- app/static/stats.js | 68 +--- app/stats.py | 67 +++- app/stats_analysis.py | 79 ++++- app/stats_dsl.py | 2 +- app/stats_features.py | 247 +++++++++++++ app/stats_insights.py | 126 +++++++ app/stats_presets.py | 94 +++++ app/stats_sources.py | 61 ++++ app/templates/_analysis_workspace.html | 37 ++ app/templates/_live_analyses.html | 18 + app/templates/dashboard.html | 5 +- app/templates/stats.html | 37 +- docs/STATS_DSL.md | 149 ++++++++ .../versions/f31a0c9286bd_saved_analyses.py | 32 ++ scripts/preview-fixture.py | 52 ++- tests/test_analysis_workspace.py | 333 ++++++++++++++++++ tests/test_app.py | 4 +- tests/test_display.py | 7 +- 31 files changed, 1986 insertions(+), 131 deletions(-) create mode 100644 app/analysis_library.py create mode 100644 app/effort_segments.py create mode 100644 app/static/analysis-charts.js create mode 100644 app/static/analysis-library.js create mode 100644 app/static/analysis.css create mode 100644 app/static/live-analyses.js create mode 100644 app/stats_features.py create mode 100644 app/stats_insights.py create mode 100644 app/stats_presets.py create mode 100644 app/stats_sources.py create mode 100644 app/templates/_analysis_workspace.html create mode 100644 app/templates/_live_analyses.html create mode 100644 migrations/versions/f31a0c9286bd_saved_analyses.py create mode 100644 tests/test_analysis_workspace.py diff --git a/README.md b/README.md index 4d7585e..3772971 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,34 @@ runs Gaps use complete owner-scoped history before filters and link to the previous run. The explorer uses persisted run summaries without loading recording samples. -Cards show metric definitions and usable-run coverage. Select a chart point or -group row to inspect contributing runs. Save named views in this browser, or -bookmark the results URL; relative queries follow the calendar as runs are added. +Cards show metric definitions and usable-input coverage. Select a chart point, +box, calendar day or group row to inspect contributing inputs. Save named +analyses to your account, organize them into investigations with notes, duplicate +them to ask a related question, and pin plots, values or tables to your live +dashboard. The library and layout follow your account across browsers/devices +connected to the same Stride server. Relative queries follow the calendar. + +Plots include line, bar, area, overlaid series, histograms, quartile boxes, +calendar heatmaps, named-cohort stacked bars, and scatter plots with metric-based +color/size. Download plots as PNG or export numeric results as CSV. Ready-made +questions cover matched-pace HR, workload, consistency, decoupling and more. +Matched-cohort baselines and persistent deviation rules expose their coverage, +reference range and contributing inputs. Flags describe statistical departures; +they are not an overtraining diagnosis. + +The DSL can query `runs`, recorded `laps`, or persisted five-minute moving +`segments`, with recording speed variability, HR coverage and speed/HR +decoupling. New imports calculate these from full-resolution recordings. After +upgrading an existing installation, run: + +```powershell +uv run --locked flask --app app db upgrade +uv run --locked flask --app app backfill-analysis +``` + +The backfill is resumable. Dashboard queries read compact summaries rather than +raw recordings; pinned analyses refresh on load, window focus and every five +minutes while the page is visible. ### Planning running routes diff --git a/app/__init__.py b/app/__init__.py index 841c796..b0037d4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -69,6 +69,9 @@ def create_app(config=None): from .routes import web app.register_blueprint(web) + from .analysis_library import library + + app.register_blueprint(library) from .planner import planner app.register_blueprint(planner) diff --git a/app/analysis_engine.py b/app/analysis_engine.py index 294cd02..a20ebbe 100644 --- a/app/analysis_engine.py +++ b/app/analysis_engine.py @@ -71,7 +71,7 @@ def formatted(number, key, q): return "—" spec = q["aggregates"].get(key) raw = spec["field"] if spec and spec["function"] not in {"count", "stddev", "sum"} else key - if raw == "pace": + if raw in {"pace", "gap"}: return format_value(number, "pace") if key == "count" or (spec and spec["function"] == "count"): return f"{number:,.0f}" @@ -130,9 +130,12 @@ def ordered(rows, ordering): def prepare(raw, zone, q, catalog): rows, windows, totals = [], {7: deque(), 28: deque()}, {7: 0.0, 28: 0.0} previous, last_day, streak = None, None, 0 - trees = [ - (spec["name"], expression_tree(spec["expression"], {**catalog})[0]) for spec in q["derived"] - ] + trees = { + spec["name"]: expression_tree(spec["expression"], catalog)[0] + for spec in q["derived"] + if "window" not in spec + } + custom_windows = {spec["name"]: deque() for spec in q["derived"] if "window" in spec} for record in raw: row = SimpleNamespace(**record._mapping) row.instant = ( @@ -150,6 +153,7 @@ def prepare(raw, zone, q, catalog): if row.distance_m > 0 else None, max_heart_rate=(row.analysis_summary or {}).get("max_hr") + or (row.analysis_summary or {}).get("summary", {}).get("max_hr") or (row.metrics or {}).get("max_heart_rate"), calories=(row.metrics or {}).get("total_calories"), training_effect=(row.metrics or {}).get("total_training_effect"), @@ -189,8 +193,17 @@ def prepare(raw, zone, q, catalog): streak = streak + 1 if last_day and (row.day - last_day).days == 1 else 1 last_day = row.day row.values["run_day_streak"] = streak - for name, tree in trees: - row.values[name] = evaluate(tree, row.values) + for spec in q["derived"]: + name = spec["name"] + if "window" not in spec: + row.values[name] = evaluate(trees[name], row.values) + continue + window = custom_windows[name] + config = spec["window"] + while window and (row.instant - window[0][0]).total_seconds() >= config["days"] * 86400: + window.popleft() + window.append((row.instant, row.values[config["field"]])) + row.values[name] = statistic([item[1] for item in window], config["function"]) rows.append(row) previous = row return rows @@ -199,9 +212,19 @@ def prepare(raw, zone, q, catalog): def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, raw_group=False): zone, catalog = ZoneInfo(timezone), catalog_for(q) start, end, prior_start = period_bounds(q, timezone) + prior_end = start + if q["compare"] and q["comparison"] == "year": + import calendar + + def previous_year(day): + return day.replace( + year=day.year - 1, day=min(day.day, calendar.monthrange(day.year - 1, day.month)[1]) + ) + + prior_start, prior_end = previous_year(start), previous_year(end) boundary = lambda day: datetime.combine(day, datetime.min.time(), zone).astimezone(UTC) # Temporal columns use every earlier run, independently of query filters. - raw = db.session.execute( + statement = ( db.select( Activity.id, Activity.title, @@ -219,8 +242,20 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r Activity.started_at < boundary(end), ) .order_by(Activity.started_at, Activity.id) - ).all() + ) + if q["source"] != "runs" and not q["all_time"]: + lower = min( + start, + prior_start if q["compare"] else start, + start - timedelta(days=q["baseline"] or 0), + ) + statement = statement.where(Activity.started_at >= boundary(lower)) + raw = db.session.execute(statement).all() history = prepare(raw, zone, q, catalog) + if q["source"] != "runs": + from .stats_sources import expand + + history = expand(history, q, catalog) if q["all_time"] and history: start = history[0].day prior_start = start @@ -242,7 +277,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r [ row for row in history - if q["compare"] and prior_start <= row.day < start and included(row) + if q["compare"] and prior_start <= row.day < prior_end and included(row) ], q["sort"], ) @@ -288,7 +323,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r return f"{token:02d}:00" if group == "run": row = next(row for row in current if row.id == token) - return row.local.strftime("%d %b %Y %H:%M") + f" · #{row.id}" + return row.local.strftime("%d %b %Y %H:%M") + ( + f" · {row.title}" if q["source"] != "runs" else f" · #{row.id}" + ) if group == "bin": unit = catalog[width["field"]][1] return f"{token * width['width']:g}–{(token + 1) * width['width']:g} {unit}" @@ -372,11 +409,16 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r kept_prior = {row.id for token in tokens for row in prior_groups[token]} prior = [row for row in prior if row.id in kept_prior] descriptors = {key: describe(key, catalog, q["aggregates"]) for key in keys} + for key, title in q["labels"].items(): + if key in descriptors: + descriptors[key] = (title, *descriptors[key][1:]) cards = [] for key in keys: number, coverage = summarize(current, key, q) before, _ = summarize(prior, key, q) title, unit, definition = descriptors[key] + if unit == "runs": + unit = q["source"] cards.append( { "key": key, @@ -438,6 +480,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r return results title, unit, _ = descriptors[key] + if unit == "runs": + unit = q["source"] pace_axis = unit == "/km" if q["trend"] == "percent_change": unit, pace_axis = "%", False @@ -459,7 +503,57 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r "type": q["chart"], "group": group, "trend": q["trend"], + "display": q["display"], + "reference": q["reference"], + "dates": [token.isoformat() for token in tokens] if group == "day" else [], + "source": q["source"], } + if q["cohorts"]: + cohorts = [{"label": cohort["name"], "values": []} for cohort in q["cohorts"]] + unassigned = {"label": "Other / missing", "values": []} + for token in tokens: + partitions = [[] for _ in range(len(cohorts) + 1)] + for row in current_groups[token]: + selected = next( + ( + i + for i, cohort in enumerate(q["cohorts"]) + if matches(row, cohort["conditions"]) + ), + len(cohorts), + ) + partitions[selected].append(row) + for partition, cohort in zip(partitions, [*cohorts, unassigned], strict=True): + cohort["values"].append(measure(partition, keys[0])) + chart["cohorts"] = [*cohorts, unassigned] + from .stats_insights import enrich + + insights = enrich( + chart, q, keys, tokens, current_groups, history, included, start, measure, catalog + ) + chart["drill_labels"] = [] + for key, series in zip(keys, chart["series"], strict=True): + histogram = series.get("histogram") + if not histogram or not histogram["counts"]: + continue + field = q["aggregates"].get(key, {}).get("field") or ( + "count" if key in q["aggregates"] else key + ) + edges = histogram["edges"] + bins = [[] for _ in histogram["counts"]] + for row in current: + number = value(row, field) + if valid(number): + index = min(len(bins) - 1, int((number - edges[0]) / histogram["width"])) + bins[index].append(row) + histogram["buckets"] = [] + for index in range(len(histogram["counts"])): + name = f"Distribution · {key} · {index + 1}: {edges[index]:g}–{edges[index + 1]:g} {catalog[field][1]}" + rows = bins[index] + evidence_groups[name] = rows + raw_evidence_groups[name] = rows + histogram["buckets"].append(name) + chart["drill_labels"].append(name) correlation = None if q["plot"]: x, y = q["plot"] @@ -467,8 +561,10 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r { "x": value(row, x), "y": value(row, y), - "id": row.id, + "id": getattr(row, "activity_id", row.id), "label": row.title + " · " + row.local.strftime("%d %b %Y"), + "color": value(row, q["color"]) if q["color"] else None, + "size": value(row, q["size"]) if q["size"] else None, } for row in rows if valid(value(row, x)) and valid(value(row, y)) @@ -486,6 +582,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r "current": plotted, "previous": points(prior) if q["compare"] else [], "correlation": correlation, + "color": catalog[q["color"]] if q["color"] else None, + "size": catalog[q["size"]] if q["size"] else None, } evidence = current if selected_bucket is not None: @@ -509,7 +607,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r for key in keys ] return { - "id": row.id, + "id": getattr(row, "activity_id", row.id), "title": row.title, "date": row.local.strftime("%d %b %Y %H:%M"), "distance": f"{row.distance_m / 1000:.2f}", @@ -526,7 +624,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r return { "cards": cards, + "source": q["source"], "chart": chart, + "insights": insights, "groups": groups, "evidence_columns": [ {"key": field, "label": catalog[field][0], "unit": catalog[field][1]} @@ -546,7 +646,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r "start": start, "end": end - timedelta(days=1), "previous_start": prior_start, - "previous_end": start - timedelta(days=1), + "previous_end": prior_end - timedelta(days=1), "page": page, "pages": pages, "evidence_count": len(evidence), diff --git a/app/analysis_library.py b/app/analysis_library.py new file mode 100644 index 0000000..2b53c8d --- /dev/null +++ b/app/analysis_library.py @@ -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("/") +@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("/") +@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("//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"}, + ) diff --git a/app/display.py b/app/display.py index a8c1073..5f6b4eb 100644 --- a/app/display.py +++ b/app/display.py @@ -7,7 +7,7 @@ import click from .analytics import analyse, numeric -ANALYSIS_VERSION = 1 +ANALYSIS_VERSION = 2 CHART_LIMIT = 750 ROUTE_TARGET = 1400 CHART_KEYS = ( @@ -104,8 +104,15 @@ def compact_routes(samples): def persist_analysis(activity): + from .effort_segments import analyze + analysis = analyse(activity) + effort, segments = analyze(activity.samples or []) + analysis["summary"].update( + {key: effort[key] for key in ("pace_variability", "heart_rate_drift", "sensor_coverage")} + ) activity.analysis_summary = {key: analysis[key] for key in ("summary", "terrain", "laps")} + activity.analysis_summary["segments"] = segments routes = compact_routes(analysis["samples"]) activity.route_geometry = [[sample["point"] for sample in segment] for segment in routes] chart_indices = evenly_spaced(len(analysis["samples"]), CHART_LIMIT) diff --git a/app/effort_segments.py b/app/effort_segments.py new file mode 100644 index 0000000..eacea44 --- /dev/null +++ b/app/effort_segments.py @@ -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 diff --git a/app/models.py b/app/models.py index 126a6bf..e0aac42 100644 --- a/app/models.py +++ b/app/models.py @@ -101,6 +101,28 @@ class PlannedRoute(db.Model): ) +class SavedAnalysis(db.Model): + id = db.Column(db.Integer, primary_key=True) + owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) + name = db.Column(db.String(100), nullable=False) + collection = db.Column(db.String(80), nullable=False, default="My analyses") + notes = db.Column(db.Text, nullable=False, default="") + query = db.Column(db.Text, nullable=False) + pinned = db.Column(db.Boolean, nullable=False, default=False) + position = db.Column(db.Integer, nullable=False, default=0) + width = db.Column(db.String(12), nullable=False, default="half") + view = db.Column(db.String(12), nullable=False, default="chart") + created_at = db.Column( + db.DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + class Friendship(db.Model): id = db.Column(db.Integer, primary_key=True) low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) diff --git a/app/routes.py b/app/routes.py index 058df85..2c2a8d0 100644 --- a/app/routes.py +++ b/app/routes.py @@ -30,7 +30,7 @@ from .analytics import pace_text from .display import stored_analysis from .extensions import db, limiter from .history import activity_page, latest_mapped_run, overview, records_for -from .models import Activity, Friendship, GarminConnection, User +from .models import Activity, Friendship, GarminConnection, SavedAnalysis, User from .services import activity_data, import_cache, import_upload, route_data from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text from .stats_analysis import EXTRA_UNITS, FUNCTIONS, GROUPS @@ -196,16 +196,34 @@ def dashboard(): record_book=records_for(current_user.id, {row.id for row in pagination.items}), mapped_run=latest_mapped_run(filters), pace_text=pace_text, + analyses=db.session.scalars( + db.select(SavedAnalysis) + .filter_by(owner_id=current_user.id, pinned=True) + .order_by(SavedAnalysis.position, SavedAnalysis.id) + ).all(), ) @web.get("/stats") @login_required def stats(): + from .analysis_library import owned, serialize + from .stats_presets import PRESETS + + saved = None + if request.args.get("analysis"): + try: + analysis_id = int(request.args["analysis"]) + except ValueError: + abort(404) + saved = owned(analysis_id) + args = dict(request.args) + if saved and "query" not in args: + args["query"] = saved.query error = None results = None try: - query = parse_query(request.args) + query = parse_query(args) page = int(request.args.get("page", "1")) results = explore(current_user.id, current_user.timezone, query, page) except (ValueError, OverflowError) as exc: @@ -218,7 +236,9 @@ def stats(): metrics=METRICS, results=results, error=error, - submitted_query=request.args.get("query", ""), + submitted_query=args.get("query", ""), + selected_analysis=serialize(saved) if saved else None, + presets=PRESETS, language={ "functions": sorted(FUNCTIONS), "groups": sorted(GROUPS), diff --git a/app/static/analysis-charts.js b/app/static/analysis-charts.js new file mode 100644 index 0000000..1f05cf3 --- /dev/null +++ b/app/static/analysis-charts.js @@ -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;ionBucket?.(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}; +})(); diff --git a/app/static/analysis-library.js b/app/static/analysis-library.js new file mode 100644 index 0000000..c516fc6 --- /dev/null +++ b/app/static/analysis-library.js @@ -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(); +})(); diff --git a/app/static/analysis.css b/app/static/analysis.css new file mode 100644 index 0000000..db5ffe1 --- /dev/null +++ b/app/static/analysis.css @@ -0,0 +1,6 @@ +.analysis-workspace,.analysis-builder,.analysis-preset-gallery{margin-bottom:20px}.analysis-library{padding:0 24px 20px}.analysis-collection{border-top:1px solid var(--line);padding:12px 0}.analysis-collection>summary,.analysis-builder>summary,.analysis-preset-gallery>summary{cursor:pointer;font-size:15px;color:var(--ink)}.analysis-library-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px}.analysis-library-item{border:1px solid var(--line);padding:14px;border-radius:8px;min-width:0}.analysis-library-item.is-current{border-color:var(--green)}.analysis-library-name{display:block;color:var(--ink);font-size:15px;overflow-wrap:anywhere}.analysis-library-item .pill{margin:6px 0}.analysis-library-item p{white-space:pre-wrap;max-height:100px;overflow:auto}.analysis-library-item .query-actions{margin-top:12px}.analysis-library-item button{font-size:12px}.analysis-builder,.analysis-preset-gallery{padding:20px 24px}.analysis-presets{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:18px}.analysis-preset{border:1px solid var(--line);border-radius:8px;padding:16px;color:var(--ink);transition:border-color .15s}.analysis-preset:hover{border-color:var(--green)}.analysis-preset h3{font-size:15px;margin:8px 0}.analysis-preset p{font-size:12px;color:var(--muted);line-height:1.7}.analysis-settings{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:16px 0}.analysis-settings label,.analysis-dialog label{display:block;font-size:12px;color:var(--muted)}.analysis-settings input,.analysis-settings select,.analysis-dialog input:not([type=checkbox]),.analysis-dialog textarea,.analysis-dialog select{display:block;width:100%;margin-top:6px;border:1px solid var(--line);border-radius:6px;background:var(--paper);color:var(--ink);padding:10px;font:inherit}.analysis-dialog{background:var(--white);color:var(--ink);border:1px solid var(--line);border-radius:12px;width:min(540px,calc(100% - 28px));max-height:85vh;overflow:auto;margin:auto;padding:24px}.analysis-dialog::backdrop{background:#0009}.analysis-dialog label{margin-top:16px}.analysis-dialog .analysis-check{display:flex;gap:10px;align-items:center}.analysis-dialog .bottom{margin-top:20px}.analysis-results .query-actions label{font-size:12px;color:var(--muted)}.analysis-results select,.widget-actions select{padding:7px;border-radius:6px;background:var(--paper);color:var(--ink);border:1px solid var(--line)}.analysis-plots{padding:0 24px 20px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}.analysis-plots:has(.analysis-chart:only-child){grid-template-columns:1fr}.analysis-chart{min-width:0}.analysis-chart h3{font-size:14px;margin-bottom:12px}.analysis-canvas{height:280px;min-width:0}.analysis-download{font-size:11px;margin-top:8px}.analysis-insights{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-top:20px}.analysis-insight{border-left:3px solid var(--green);background:var(--white);padding:18px;border-radius:6px;min-width:0}.analysis-insight h3{font-size:15px;margin-bottom:8px}.analysis-insight p{font-size:12px;color:var(--muted);margin:8px 0;line-height:1.7}.analysis-insight strong{display:block;font-size:13px;margin:12px 0}.analysis-flags{display:flex;gap:8px;flex-wrap:wrap}.analysis-flags .button{padding:6px 8px;font-size:11px}.analysis-calendar{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px}.analysis-month{grid-column:1/-1;font-size:12px;color:var(--muted);padding-top:12px}.analysis-day{min-height:42px;border:0;border-radius:4px;color:var(--ink);padding:5px;display:flex;flex-direction:column;align-items:center;gap:2px}.analysis-day span{font-size:11px}.analysis-day small{font-size:10px}.analysis-day:hover{outline:1px solid var(--green)}#live-analyses{margin-bottom:28px}#live-analyses>.section-head{padding:8px 0 18px}.live-analysis-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.live-analysis-widget{min-width:0}.widget-full,.live-empty{grid-column:1/-1}.live-widget-body{padding:0 22px 18px}.live-widget-body .analysis-chart{margin-bottom:18px}.live-widget-body .analysis-canvas{height:245px}.live-widget-insights{padding:0 22px}.live-widget-insights .analysis-insight{background:var(--paper);margin-bottom:12px}.live-widget-values{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.live-widget-values strong{display:block;font-size:28px;color:var(--ink);margin:8px 0;overflow-wrap:anywhere}.live-widget-values small{display:block}.widget-actions{display:flex;gap:14px;flex-wrap:wrap;align-items:center;padding:12px 22px;border-top:1px solid var(--line)}.widget-status{padding:0 22px 16px}.widget-notes{padding:0 22px 16px}.widget-notes summary{font-size:12px;color:var(--muted);cursor:pointer}.widget-notes p{font-size:12px;white-space:pre-wrap;color:var(--muted);margin-top:10px}.live-empty{padding:24px}.live-empty p{color:var(--muted);margin:10px 0 18px}.live-analysis-widget .section-head h3{font-size:17px}.analysis-day:focus-visible{outline:2px solid var(--green)} +@media(max-width:1000px){.analysis-presets,.analysis-library-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.analysis-plots{grid-template-columns:1fr}} +@media(max-width:700px){.analysis-presets,.analysis-library-grid,.live-analysis-grid,.analysis-insights{grid-template-columns:1fr}.analysis-settings{grid-template-columns:1fr 1fr}.analysis-plots{padding:0 16px 16px}.analysis-workspace .section-head{flex-wrap:wrap}.live-analysis-widget .section-head{flex-wrap:wrap;gap:12px}.analysis-canvas{height:240px}} +@media(max-width:420px){.analysis-settings{grid-template-columns:1fr}.live-widget-values{grid-template-columns:1fr}.analysis-library{padding:0 16px 16px}} + +.live-analysis-grid{align-items:start}.analysis-weekday{text-align:center;font-size:11px;color:var(--muted)} diff --git a/app/static/live-analyses.js b/app/static/live-analyses.js new file mode 100644 index 0000000..85e6f40 --- /dev/null +++ b/app/static/live-analyses.js @@ -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{ + 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(); +})(); diff --git a/app/static/stats-editor.js b/app/static/stats-editor.js index ee1f307..e0c5b7d 100644 --- a/app/static/stats-editor.js +++ b/app/static/stats-editor.js @@ -24,6 +24,10 @@ const templates = [ ['runs', 'Your recorded running activities'], + ['laps', 'Recorded lap inputs from compact analysis'], + ['segments', 'Five-minute moving segments from full-resolution recordings'], + ['cohort "Easy" where heart_rate <= 145 bpm', 'First-match partitions for a stacked additive summary'], + ['compare previous year', 'Same local-date interval in the preceding year'], ['during last 12 weeks', 'Rolling period ending today'], ['during last 6 months', 'Calendar months'], ['during last 30 days', 'Rolling days'], @@ -41,7 +45,14 @@ ['trend change', 'Difference from the preceding bucket'], ['trend percent_change', 'Percentage change from the preceding bucket'], ['plot speed vs heart_rate', 'Run-level scatter plot and Pearson correlation'], - ['chart bar', 'Choose auto, line, bar or none'], + ['chart bar', 'Choose auto, line, bar, area, histogram, box, heatmap or none'], + ['display overlay', 'Plot multiple series together with axes by unit'], + ['baseline previous 90 days', 'Earlier matched-run baseline before the query starts'], + ['flag above baseline 1.5 sd min 3 runs sustained 2 buckets', 'Persistent descriptive deviation with minimum coverage'], + ['color by elevation_per_km', 'Scatter color encodes this numeric metric'], + ['size by distance', 'Scatter size encodes this numeric metric'], + ['reference heart_rate = 150 bpm', 'Draw a reference line for a selected metric'], + ['let hr_14d = rolling_mean(heart_rate, 14 days)', 'Custom rolling mean, sum or count before filtering'], ['by distance bins 5 km', 'Group runs into half-open distance ranges'], ['during 2026-07-01 to 2026-09-30', 'Inclusive local-date range'], ['ending 2026-10-02', 'Optional end date for a rolling period'], @@ -71,7 +82,7 @@ const word = stream.current(); if (names.has(word) || availableMetrics().some(metric=>metric.name===word)) return 'variable-2'; if (language.functions.includes(word)) return 'builtin'; - if (/^(runs|during|last|ending|where|and|or|between|summarize|by|compare|previous|period|none|has|missing|title|contains|sort|asc|desc|to|let|having|rank|take|limit|trend|rolling|cumulative|change|percent_change|plot|vs|chart|bins|all|time)$/.test(word)) return 'keyword'; + if (/^(runs|during|last|ending|where|and|or|between|summarize|by|compare|previous|period|none|has|missing|title|contains|sort|asc|desc|to|let|having|rank|take|limit|trend|rolling|cumulative|change|percent_change|plot|vs|chart|bins|all|time|baseline|flag|sd|min|sustained|buckets|above|below|outside|display|overlay|separate|color|size|reference|as)$/.test(word)) return 'keyword'; if (/^(day|days|week|weeks|month|months|km|m|bpm|spm|W|ms|h|min|s|date)$/.test(word)) return 'atom'; return null; } @@ -89,7 +100,7 @@ } else if (/^\s*rank by (?:\w+|\w+\(\w*\))\s+\w*$/.test(line)) { list=['asc','desc'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Group ranking order'})); } else if (/^\s*chart\s+\w*$/.test(line)) { - list=['auto','line','bar','none'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Chart presentation'})); + list=['auto','line','bar','area','histogram','box','heatmap','stacked','none'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Chart presentation'})); } else if (/^\s*trend\s+[\w ]*$/.test(line)) { from={line:cursor.line,ch:line.indexOf('trend')+6};const prefix=line.slice(from.ch); list=['cumulative','rolling 4 buckets','change','percent_change'].filter(text=>text.startsWith(prefix)).map(text=>({text,detail:'Transform displayed time buckets'})); @@ -192,6 +203,7 @@ // Submit only the authoritative DSL. Helper controls never override it. const url=new URL(form.action||location.pathname,location.origin); url.search=new URLSearchParams({query:text}); + const analysis=new URL(location.href).searchParams.get('analysis');if(analysis)url.searchParams.set('analysis',analysis); location.assign(url); }); document.getElementById('format-query').onclick=async()=>{ diff --git a/app/static/stats.js b/app/static/stats.js index d747330..323fdc5 100644 --- a/app/static/stats.js +++ b/app/static/stats.js @@ -2,48 +2,22 @@ 'use strict'; const root = document.getElementById('stats-studio'); if (!root) return; - const form = document.getElementById('explorer-form'); - const input = document.getElementById('query-input'); const status = document.getElementById('studio-status'); - const value = name => form.elements.namedItem(name).value; - const storageKey = `stride.stats.views.${root.dataset.owner}`; - const views = document.getElementById('saved-views'); - let saved = []; - try { const parsed = JSON.parse(localStorage.getItem(storageKey) || '[]'); if (Array.isArray(parsed)) saved = parsed.filter(x => typeof x.name === 'string' && typeof x.query === 'string').slice(0, 12); } catch (_) { /* Storage may be unavailable. */ } - function renderSaved() { - views.replaceChildren(); - saved.forEach((view, index) => { - const wrapper = document.createElement('span'); - const link = document.createElement('a'); link.textContent = view.name; - link.href = '/stats?' + new URLSearchParams({query: view.query}); - const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '×'; remove.setAttribute('aria-label', `Remove ${view.name}`); - remove.onclick = () => { saved.splice(index, 1); persist(); renderSaved(); }; - wrapper.append(link, remove); views.append(wrapper); - }); - } - function persist() { try { localStorage.setItem(storageKey, JSON.stringify(saved)); status.textContent = 'Saved views are stored in this browser.'; } catch (_) { status.textContent = 'Browser storage is unavailable; bookmark this results page to save the question.'; } } - document.getElementById('save-view').onclick = () => { - const name = window.prompt('Name this view', `${value('metric').replaceAll('_', ' ')} · last ${value('weeks')} weeks`); - if (!name || !name.trim()) return; - const query = window.strideQueryEditor ? window.strideQueryEditor.getValue() : input.value; - saved = [{name: name.trim().slice(0, 80), query}, ...saved.filter(v => v.query !== query)].slice(0, 12); - persist(); renderSaved(); - }; - renderSaved(); const data = document.getElementById('stats-data'); if (!data || typeof Chart === 'undefined') return; const chartData = JSON.parse(data.textContent); - const pace = n => { const s = Math.round(n); return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`; }; const clear = document.getElementById('clear-selection'); const tbody = document.getElementById('evidence-body'); + let selectionScope = 'trend'; const original = [...tbody.children].map(row => row.cloneNode(true)); const pagination = root.querySelector('.studio-pagination'); const period = document.getElementById('evidence-period'); const originalPagination = [...pagination.children].map(node => node.cloneNode(true)); let pending = null; - clear.onclick = () => { pending?.abort(); period.value = ''; tbody.replaceChildren(...original.map(row => row.cloneNode(true))); pagination.replaceChildren(...originalPagination.map(node => node.cloneNode(true))); clear.hidden = true; document.getElementById('evidence-heading').textContent = 'Contributing runs'; status.textContent = ''; }; - period.onchange = () => { if (period.value) loadBucket(period.value); else clear.click(); }; + clear.onclick = () => { pending?.abort(); period.value = ''; tbody.replaceChildren(...original.map(row => row.cloneNode(true))); pagination.replaceChildren(...originalPagination.map(node => node.cloneNode(true))); clear.hidden = true; document.getElementById('evidence-heading').textContent = `Contributing ${root.dataset.source}`; status.textContent = ''; }; + period.onchange = () => { if (period.value) loadBucket(period.value,1,selectionScope); else clear.click(); }; async function loadBucket(bucket, page = 1, scope = 'trend') { + selectionScope=scope; pending?.abort(); const controller = new AbortController(); pending = controller; const url = new URL('/stats/runs', location.origin); @@ -67,7 +41,7 @@ [`${row.distance} km`, ...row.values].forEach(text => { const cell = document.createElement('td'); cell.textContent = text; tr.append(cell); }); tbody.append(tr); }); - document.getElementById('evidence-heading').textContent = `${bucket} · ${result.count} runs`; + document.getElementById('evidence-heading').textContent = `${bucket} · ${result.count} ${root.dataset.source}`; period.value = bucket; pagination.replaceChildren(); const label = document.createElement('span'); label.textContent = `Page ${result.page} of ${result.pages} · selected period`; pagination.append(label); @@ -78,30 +52,10 @@ } catch (error) { if (error.name !== 'AbortError') status.textContent = 'Could not load this period. Try again.'; } } root.querySelectorAll('[data-bucket]').forEach(button=>button.onclick=()=>loadBucket(button.dataset.bucket,1,'group')); - chartData.series.forEach((series, index) => { - const canvas = document.getElementById(`stats-chart-${index}`); - if (!canvas) return; - new Chart(canvas, { - type: chartData.type === 'auto' ? (['bin','weekday','hour','run','all'].includes(chartData.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line') : chartData.type, - data: {labels: chartData.labels, datasets: [ - {label:'Current', data:series.values, borderColor:'#f48459', backgroundColor:'#f4845940', borderWidth:2, pointRadius:3, tension:.2, spanGaps:false}, - ...(series.previous.length ? [{label:'Previous (aligned)', data:series.previous, borderColor:'#9ba4a5', backgroundColor:'#9ba4a526', borderDash:[5,5], borderWidth:2, pointRadius:2, spanGaps:false}] : []) - ]}, - options: {responsive:true, maintainAspectRatio:false, animation:false, - plugins:{legend:{labels:{color:'#bcc4c5'}}, tooltip:{callbacks:{label:c => `${c.dataset.label}: ${c.parsed.y == null ? '—' : series.pace_axis ? pace(c.parsed.y) : c.parsed.y.toFixed(2)} ${series.unit}`}}}, - scales:{x:{ticks:{color:'#9ba4a5',maxTicksLimit:5},grid:{display:false}},y:{title:{display:true,text:series.unit,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>series.pace_axis?pace(n):new Intl.NumberFormat(undefined,{maximumFractionDigits:2}).format(n)},grid:{color:'#303638'}}}, - onClick:(_, elements) => { - const point = elements.find(e => e.datasetIndex === 0); if (!point) return; - const bucket = chartData.labels[point.index]; - loadBucket(bucket); - } - } - }); - }); - const scatter=chartData.scatter; - const scatterCanvas=document.getElementById('stats-scatter'); - if(scatter&&scatterCanvas){ - const numeric=(n,info)=>info[1]==='/km'?pace(n):new Intl.NumberFormat(undefined,{maximumFractionDigits:2}).format(n); - new Chart(scatterCanvas,{type:'scatter',data:{datasets:[{label:'Current',data:scatter.current,backgroundColor:'#f48459',pointRadius:4},...(scatter.previous.length?[{label:'Previous',data:scatter.previous,backgroundColor:'#9ba4a5',pointRadius:3}]:[])]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#bcc4c5'}},tooltip:{callbacks:{label:c=>`${c.raw.label}: ${numeric(c.raw.x,scatter.x)} ${scatter.x[1]} / ${numeric(c.raw.y,scatter.y)} ${scatter.y[1]}`}}},scales:{x:{title:{display:true,text:`${scatter.x[0]} (${scatter.x[1]})`,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>numeric(n,scatter.x)},grid:{color:'#303638'}},y:{title:{display:true,text:`${scatter.y[0]} (${scatter.y[1]})`,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>numeric(n,scatter.y)},grid:{color:'#303638'}}},onClick:(_,elements,chart)=>{const point=elements[0];if(point){const row=chart.data.datasets[point.datasetIndex].data[point.index];location.assign(`/activities/${row.id}`);}}}}); - } + const plots=document.getElementById('analysis-plots'); + if(plots)StrideCharts.render(plots,chartData,bucket=>loadBucket(bucket)); + const insightPanel=document.getElementById('analysis-insights'); + if(insightPanel)StrideCharts.insights(insightPanel,JSON.parse(document.getElementById('insights-data').textContent),bucket=>loadBucket(bucket,1,'group')); + const initialBucket=new URL(location.href).searchParams.get('bucket'); + if(initialBucket)loadBucket(initialBucket,1,new URL(location.href).searchParams.get('scope')||'trend'); })(); diff --git a/app/stats.py b/app/stats.py index 58bbafa..b2c7eca 100644 --- a/app/stats.py +++ b/app/stats.py @@ -61,6 +61,16 @@ DEFAULT = { "derived": [], "aggregates": {}, "filter_groups": [], + "display": "separate", + "baseline": None, + "flag": None, + "color": None, + "size": None, + "labels": {}, + "reference": None, + "source": "runs", + "cohorts": [], + "comparison": "period", } @@ -121,7 +131,7 @@ def query_text(q): period = f"during last {q['days']} days" else: period = f"during last {q['weeks']} weeks" - lines = ["runs", " " + period] + lines = [q["source"], " " + period] for spec in q["derived"]: lines.append(f" let {spec['name']} = {spec['expression']}") if q["end"] and not q["start"]: @@ -166,7 +176,11 @@ def query_text(q): ) for condition in q["filters"]: lines.append(clause(condition)) - metrics = ", ".join(query_metrics(q)) + metrics = ", ".join( + key + + (f" as {json.dumps(q['labels'][key], ensure_ascii=False)}" if key in q["labels"] else "") + for key in query_metrics(q) + ) lines.extend( [ f" summarize {metrics}", @@ -175,7 +189,14 @@ def query_text(q): if q["bin"] else f" by {q['group']}" ), - " compare " + ("previous period" if q["compare"] else "none"), + " compare " + + ( + "previous year" + if q["comparison"] == "year" + else "previous period" + if q["compare"] + else "none" + ), ] ) if q["sort"] != ["date", "desc"]: @@ -188,7 +209,7 @@ def query_text(q): "runs" if spec and spec["function"] == "count" else "sec/km" - if spec and field == "pace" and spec["function"] in {"sum", "stddev"} + if spec and field in {"pace", "gap"} and spec["function"] in {"sum", "stddev"} else next(iter(units[field])) ) lines.append(f" having {key} {condition['op']} {number_text(condition['value'])} {unit}") @@ -203,6 +224,25 @@ def query_text(q): lines.append(f" plot {q['plot'][0]} vs {q['plot'][1]}") if q["chart"] != "auto": lines.append(f" chart {q['chart']}") + if q["display"] != "separate": + lines.append(f" display {q['display']}") + if q["baseline"]: + lines.append(f" baseline previous {q['baseline']} days") + if q["flag"]: + flag = q["flag"] + lines.append( + f" flag {flag['direction']} baseline {number_text(flag['sd'])} sd min {flag['minimum']} runs sustained {flag['sustained']} buckets" + ) + for key in ("color", "size"): + if q[key]: + lines.append(f" {key} by {q[key]}") + if q["reference"]: + ref = q["reference"] + lines.append(f" reference {ref['field']} = {ref['raw']} {ref['unit']}") + for cohort in q["cohorts"]: + lines.append( + f" cohort {json.dumps(cohort['name'], ensure_ascii=False)} where {cohort['source']}" + ) return "\n".join(lines) @@ -254,16 +294,17 @@ def metric_value(row, key): if key == "heart_rate": return row.avg_hr if key in {"distance", "duration", "elevation"}: - return ( - getattr( - row, - {"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[ - key - ], - ) - / {"distance": 1000, "duration": 3600, "elevation": 1}[key] + raw = getattr( + row, + {"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[key], ) - return (row.analysis_summary or {}).get(key) + return ( + raw / {"distance": 1000, "duration": 3600, "elevation": 1}[key] + if isinstance(raw, (int, float)) and math.isfinite(raw) + else None + ) + summary = row.analysis_summary or {} + return summary.get("summary", summary).get(key) def aggregate(rows, key): diff --git a/app/stats_analysis.py b/app/stats_analysis.py index a07fec8..41bd5e2 100644 --- a/app/stats_analysis.py +++ b/app/stats_analysis.py @@ -25,6 +25,26 @@ FUNCTIONS = { } GROUPS = {"day", "week", "month", "year", "weekday", "hour", "run", "all"} EXTRA_METRICS = { + "pace_variability": ( + "Speed variability", + "%", + "Duration-weighted speed coefficient of variation across usable moving recording intervals. Lower means steadier recorded speed.", + ), + "heart_rate_drift": ( + "Speed/HR decoupling", + "%", + "Percent reduction in speed-to-HR efficiency from the first to second moving-time half. Positive means lower efficiency later. Requires four moving minutes and 80% HR coverage in each half; includes warm-up and terrain changes.", + ), + "sensor_coverage": ( + "Moving HR coverage", + "%", + "Percent of usable moving recording time with positive recorded heart rate. Gaps over ten seconds and pauses are excluded.", + ), + "gap": ( + "Grade-adjusted pace", + "/km", + "Modelled grade-adjusted pace from persisted recording analysis using Minetti running cost. Missing terrain stays missing.", + ), "count": ("Runs", "runs", "Number of runs."), "speed": ("Speed", "km/h", "Recorded distance divided by timer duration."), "gap_since_previous_run": ( @@ -91,6 +111,10 @@ EXTRA_METRICS = { ), } EXTRA_UNITS = { + "pace_variability": {"%": 1}, + "heart_rate_drift": {"%": 1}, + "sensor_coverage": {"%": 1}, + "gap": {"/km": 1}, "count": {"runs": 1}, "speed": {"km/h": 1, "m/s": 3.6}, "gap_since_previous_run": {"days": 1, "h": 1 / 24, "min": 1 / 1440}, @@ -131,7 +155,7 @@ def expression_tree(source, catalog, line=1): # Pace values inside arithmetic are seconds per km, not a formatted string. return ( "sec/km" - if node.id == "pace" + if catalog[node.id][1] == "/km" else "h" if node.id == "duration" else catalog[node.id][1] @@ -199,6 +223,9 @@ def evaluate(tree, values): def parse_analysis(text, metrics, defaults): if not isinstance(text, str) or len(text) > 4000: raise QueryError("Keep the query under 4,000 characters.") + from .stats_features import extract, validate + + text, features = extract(text) catalog, units = dict(metrics), {**UNITS, **EXTRA_UNITS} lines = text.splitlines() derived, aggregates, seen, locations = [], {}, set(), {} @@ -258,14 +285,26 @@ def parse_analysis(text, metrics, defaults): raise QueryError( "Choose a new lowercase metric name; at most eight calculations.", number ) - _, unit = expression_tree(expression, catalog, number) + window = re.fullmatch(r"rolling_(mean|sum|count)\((\w+), (\d+) days\)", expression) + if window: + function, field, days = window.groups() + if field not in catalog or not 1 <= int(days) <= 365: + raise QueryError( + "Rolling functions need an earlier metric and 1–365 days.", number + ) + unit = "runs" if function == "count" else catalog[field][1] + else: + _, unit = expression_tree(expression, catalog, number) catalog[name] = ( name.replace("_", " ").capitalize(), unit, f"Calculated per run: {expression}. Missing inputs or division by zero give missing data.", ) units[name] = {unit: 1} - derived.append({"name": name, "expression": expression, "unit": unit}) + spec = {"name": name, "expression": expression, "unit": unit} + if window: + spec["window"] = {"function": function, "field": field, "days": int(days)} + derived.append(spec) lines[index] = "" elif line == "during all time": once("all-time period", number) @@ -335,14 +374,14 @@ def parse_analysis(text, metrics, defaults): {"runs": 1} if function == "count" else {"sec/km": 1} - if field == "pace" and function in {"sum", "stddev"} + if catalog[field][1] == "/km" and function in {"sum", "stddev"} else units[field] ) unit = unit or next(iter(accepted)) if unit not in accepted: raise QueryError(f"Use {' or '.join(accepted)} for {key}.", number) try: - if field == "pace" and unit == "/km" and ":" in raw: + if unit == "/km" and ":" in raw: minutes, seconds = raw.split(":") if not re.fullmatch(r"\d+:\d{2}", raw) or int(seconds) >= 60: raise ValueError @@ -394,6 +433,7 @@ def parse_analysis(text, metrics, defaults): ) q = parse_dsl("\n".join(lines), catalog, defaults, units=units) q.update(options) + q.update(features) q["derived"], q["aggregates"] = derived, aggregates if group: q["group"] = group @@ -440,6 +480,33 @@ def parse_analysis(text, metrics, defaults): groups[-1].extend(batch) position += count q["filter_groups"], q["filters"] = groups, [] + if q["reference"]: + ref = q["reference"] + checked = parse_dsl( + f"runs\nwhere {ref['field']} = {ref['raw']} {ref['unit']}\nsummarize {ref['field']}", + catalog, + defaults, + units, + ) + ref["value"] = checked["filters"][0]["value"] + if ref["field"] not in selected: + raise QueryError("Include the reference metric in summarize.") + for cohort in q["cohorts"]: + match = re.fullmatch(r"(\w+) (.+)", cohort["source"]) + if not match or match[1] not in catalog: + raise QueryError("Cohorts need a numeric metric condition.") + field, condition = match.groups() + cohort_catalog = {**catalog, "cohort_input": catalog[field]} + checked = parse_dsl( + f"runs\nwhere cohort_input {condition}\nsummarize {field}", + cohort_catalog, + defaults, + {**units, "cohort_input": units[field]}, + ) + cohort["conditions"] = [{**item, "field": field} for item in checked["filters"]] + if not cohort["conditions"]: + raise QueryError("Supply a cohort comparison with a value and unit.") + validate(q, catalog) return q @@ -454,7 +521,7 @@ def describe(key, catalog, aggregates): else f"{function.replace('_', ' ').capitalize()} · {catalog[field][0].lower()}" ) unit = "runs" if function == "count" else catalog[field][1] - if field == "pace" and function in {"stddev", "sum"}: + if field and catalog[field][1] == "/km" and function in {"stddev", "sum"}: unit = "sec/km" definition = ( ( diff --git a/app/stats_dsl.py b/app/stats_dsl.py index 1d6a1e6..4ebd92a 100644 --- a/app/stats_dsl.py +++ b/app/stats_dsl.py @@ -55,7 +55,7 @@ def parse_dsl(text, metrics, defaults, units=None): def amount(key, raw, unit, line): if unit not in units[key]: raise QueryError(f"Use {' or '.join(units[key])} for {key}.", line) - if key == "pace" and ":" in raw: + if unit == "/km" and ":" in raw: minutes, seconds = raw.split(":") if not minutes.isdecimal() or not seconds.isdecimal() or int(seconds) >= 60: raise QueryError("Write pace like 5:30 /km (seconds from 00 to 59).", line) diff --git a/app/stats_features.py b/app/stats_features.py new file mode 100644 index 0000000..598749f --- /dev/null +++ b/app/stats_features.py @@ -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.") diff --git a/app/stats_insights.py b/app/stats_insights.py new file mode 100644 index 0000000..594fa5f --- /dev/null +++ b/app/stats_insights.py @@ -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 diff --git a/app/stats_presets.py b/app/stats_presets.py new file mode 100644 index 0000000..9a47119 --- /dev/null +++ b/app/stats_presets.py @@ -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", + }, +] diff --git a/app/stats_sources.py b/app/stats_sources.py new file mode 100644 index 0000000..7877b02 --- /dev/null +++ b/app/stats_sources.py @@ -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 diff --git a/app/templates/_analysis_workspace.html b/app/templates/_analysis_workspace.html new file mode 100644 index 0000000..9b0c6c6 --- /dev/null +++ b/app/templates/_analysis_workspace.html @@ -0,0 +1,37 @@ +
+
ACCOUNT LIBRARY

Investigations

+

Loading your saved analyses…

+
+ +
+

Save analysis

+ + + +
+ + +
+
+
+
+ Build a comparable-effort investigation +
+
+ + + + + + + +
+

Segments also require ≤5% speed variation, ≥80% moving HR coverage and ≥4 minutes. Matching pace and hills narrows the cohort; conditions can still differ.

+ +
+
+ + diff --git a/app/templates/_live_analyses.html b/app/templates/_live_analyses.html new file mode 100644 index 0000000..6d3d11d --- /dev/null +++ b/app/templates/_live_analyses.html @@ -0,0 +1,18 @@ +
+
LIVE OBSERVATORY

Your training signals

Account-saved analyses · refreshed on page load, focus and every five minutes

+
+ {% for analysis in analyses %} +
+
{{ analysis.collection }}

{{ analysis.name }}

Explore →
+

Loading live results…

+
+ {% if analysis.notes %}
Investigation notes

{{ analysis.notes }}

{% endif %} +
+

+
+ {% else %} +

Keep watching a useful question

Explore matched-pace heart rate, workload or consistency in Stats, then pin your analysis here.

Open analysis studio
+ {% endfor %} +
+

+
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 787ea68..b7cf15e 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,10 +1,11 @@ {% extends 'base.html' %} {% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %} {% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %} -{% block head %}{% endblock %} +{% block head %}{% endblock %} {% block content %} {% if not personal %}← Friends{% endif %}

{{ 'Your running' if personal else profile.display_name ~ '’s running' }}

{{ 'Distance, effort, and your recent sessions.' if personal else 'Activities shared with friends.' }}

{% if not personal %}Friends · Shared runs{% endif %}
+{% if personal %}{% include '_live_analyses.html' %}{% endif %}
LATEST RUN / ROUTE REPLAY

{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}

{% if mapped_run %}

{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km

View run {{ icon('arrow') }}{% else %}

Import a run with GPS to see your route in motion.

{% endif %} @@ -26,4 +27,4 @@ {% endif %}
SESSION HISTORY

{{ 'Your activities' if personal else 'Shared activities' }}

{{ stats.count }} activities
{% include '_activities.html' %}
{% endblock %} -{% block scripts %}{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/stats.html b/app/templates/stats.html index 564be93..54ae41b 100644 --- a/app/templates/stats.html +++ b/app/templates/stats.html @@ -1,17 +1,17 @@ {% extends 'base.html' %} {% block title %}Stats studio · Stride{% endblock %} -{% block head %}{% endblock %} +{% block head %}{% endblock %} {% block content %} -
-
YOUR RUNNING / STATS STUDIO

Your runs. Your questions.

Write a query, explore the pattern, and inspect the runs behind it.

-
+
+
YOUR RUNNING / STATS STUDIO

{{ selected_analysis.name if selected_analysis else "Your runs. Your questions." }}

Write a query, explore the pattern, and inspect the runs behind it.

+{% include '_analysis_workspace.html' %}
RUNNING QUERYAn analysis language for your running history.
Checking query...Ctrl / Cmd Enter to run
{% if error %}{% endif %}
-
Whole-run metrics · {{ current_user.timezone }}
Lower pace values mean faster running.
+
{{ 'Whole-run metrics' if query.source == 'runs' else 'Recorded laps' if query.source == 'laps' else 'Five-minute moving segments' }} · {{ current_user.timezone }}
Lower pace values mean faster running.
Language reference & examples

Filter and compare

during last 6 months
 where distance between 8 and 20 km
 and pace <= 5:30 /km
@@ -23,7 +23,13 @@ rank by mean(distance) desc
 limit 10
 trend rolling 4 buckets
 let efficiency = speed / heart_rate
-plot pace vs heart_rate

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.

Metrics · select up to six

{% for key, info in metrics.items() %}{% endfor %}

duration accepts h, min or s; distance accepts km or m. Use has / missing to select recorded sensor coverage.

+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"

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.

Metrics · select up to six

{% for key, info in metrics.items() %}{% endfor %}

duration accepts h, min or s; distance accepts km or m. Use has / missing to select recorded sensor coverage.

Guided controls

Adjust the basic clauses without removing additional metrics and filters.

Show and
{% 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 %} weeks, grouped by .
@@ -37,19 +43,18 @@ plot pace vs heart_rate

Functions: sum, mean, weighte

- {% if results %} -

{{ results.count }} matching {{ 'run' if results.count == 1 else 'runs' }}

{{ results.start }} – {{ results.end }} · {{ '%.1f'|format(results.distance) }} km{% if query.compare %}
Compared with {{ results.previous_start }} – {{ results.previous_end }} · {{ results.prior_count }} runs{% endif %}

+

{{ results.count }} matching {{ query.source }}

{{ results.start }} – {{ results.end }} · {{ '%.1f'|format(results.distance) }} km{% if query.compare %}
Compared with {{ results.previous_start }} – {{ results.previous_end }} · {{ results.prior_count }} runs{% endif %}

{% if not results.count %}

No runs match this question.

Try a longer period or widen the distance and heart rate filters.

{% else %} -
{% for card in results.cards %}
{{ card.label }}
{{ card.value }}{{ card.unit }}
{% if query.compare %}

Previous: {{ card.previous }} {{ card.unit }}{% if card.delta %} · {{ card.delta }} {{ 'sec/km' if card.unit == '/km' else card.unit }}{% endif %}

{% endif %}

{{ card.coverage }} of {{ results.count }} runs with usable data

How this is calculated

{{ card.definition }}{% if card.key in ('cadence','power','ground_contact','stride_length') %} Coverage counts runs, not sensor recording time.{% endif %}

{% endfor %}
-{% if query.chart != 'none' %}
TRENDS / {{ query.group|upper }}

{{ 'Your question over time' if query.group in ('day','week','month','year') else 'Your question by ' ~ query.group }}

Solid: current · Dashed: previous
{% for series in results.chart.series %}

{{ series.label }} {{ series.unit }}

{% endfor %}

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.

{% endif %} +
{% for card in results.cards %}
{{ card.label }}
{{ card.value }}{{ card.unit }}
{% if query.compare %}

Previous: {{ card.previous }} {{ card.unit }}{% if card.delta %} · {{ card.delta }} {{ 'sec/km' if card.unit == '/km' else card.unit }}{% endif %}

{% endif %}

{{ card.coverage }} of {{ results.count }} {{ query.source }} with usable data

How this is calculated

{{ card.definition }}{% if card.key in ('cadence','power','ground_contact','stride_length') %} Coverage counts runs, not sensor recording time.{% endif %}

{% endfor %}
+
EXPLORE / VISUALIZE

Your question, visualized

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 %}

+
{% endif %} -{% if results.chart.scatter %}
RELATIONSHIP / RUN AVERAGES

{{ results.chart.scatter.x[0] }} vs {{ results.chart.scatter.y[0] }}

{% if results.chart.scatter.correlation is not none %}Pearson r = {{ '%.3f'|format(results.chart.scatter.correlation) }}{% else %}Correlation unavailable{% endif %}

Each point is one run. Select a point to open that run. Correlation describes association; it does not establish cause.

{% endif %} -
ANALYSIS / GROUPED RESULTS

{{ query.group|capitalize }} breakdown

Export CSV
{% for card in results.cards %}{% endfor %}{% for group in results.groups %}{% for value in group['values'] %}{% endfor %}{% endfor %}
GROUPRUNS{{ card.label|upper }} · {{ card.unit }}
{{ group.count }}{{ value }}
-
BEHIND THE NUMBERS

Contributing runs

Export runs
{% for card in results.evidence_columns %}{% endfor %}{% for row in results.rows %}{% for value in row['values'] %}{% endfor %}{% else %}{% endfor %}
RUN / LOCAL DATEDISTANCE{{ card.label|upper }} · {{ card.unit }}
{{ row.title }}
{{ row.date }}
{% if results.show_previous %}Previous: {% if row.previous_run %}{{ row.previous_run.date }} · {{ row.previous_run.title }}{% else %}No earlier recorded run{% endif %}{% endif %}
{{ row.distance }} km{{ value }}
No matching runs.
Page {{ results.page }} of {{ results.pages }} · up to 25 runs per page{% if results.page > 1 %}Previous{% endif %}{% if results.page < results.pages %}Next{% endif %}
- +
ANALYSIS / GROUPED RESULTS

{{ query.group|capitalize }} breakdown

Export CSV
{% for card in results.cards %}{% endfor %}{% for group in results.groups %}{% for value in group['values'] %}{% endfor %}{% endfor %}
GROUP{{ query.source|upper }}{{ card.label|upper }} · {{ card.unit }}
{{ group.count }}{{ value }}
+
BEHIND THE NUMBERS

Contributing {{ query.source }}

Export runs
{% for card in results.evidence_columns %}{% endfor %}{% for row in results.rows %}{% for value in row['values'] %}{% endfor %}{% else %}{% endfor %}
{{ query.source|upper }} / LOCAL DATEDISTANCE{{ card.label|upper }} · {{ card.unit }}
{{ row.title }}
{{ row.date }}
{% if results.show_previous %}Previous: {% if row.previous_run %}{{ row.previous_run.date }} · {{ row.previous_run.title }}{% else %}No earlier recorded run{% endif %}{% endif %}
{{ row.distance }} km{{ value }}
No matching runs.
Page {{ results.page }} of {{ results.pages }} · up to 25 {{ query.source }} per page{% if results.page > 1 %}Previous{% endif %}{% if results.page < results.pages %}Next{% endif %}
+ {% endif %} -

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.

+

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.

{% endblock %} -{% block scripts %}{% endblock %} +{% block scripts %}{% endblock %} diff --git a/docs/STATS_DSL.md b/docs/STATS_DSL.md index 6c955e0..28ecde4 100644 --- a/docs/STATS_DSL.md +++ b/docs/STATS_DSL.md @@ -1,5 +1,154 @@ # Run analysis DSL +## Account library and live dashboard + +Save an analysis with a name, investigation collection and notes. **Save as new** +or **Duplicate** creates an independent query to explore another question. +**Pin live analysis** adds it to your personal dashboard, where it can display a +plot, summary values or a grouped table. Width and order are saved to your +account. All queries and widget requests enforce owner scope, including shared +friend profiles. Dashboard widgets refresh on load/focus and every five minutes +while visible; each widget retains its own query date range. Relative periods +advance with the calendar. Results are recomputed rather than saved as snapshots. + +## Matched effort, reference ranges and persistent deviations + +```text +runs + during last 12 weeks + where pace between 5:20 and 5:40 /km + and elevation_per_km <= 10 m/km + and has heart_rate + summarize weighted_mean(heart_rate) as "Matched-pace HR" + by week + compare none + baseline previous 90 days + flag above baseline 1.5 sd min 3 runs sustained 2 buckets + chart line +``` + +The baseline is the stated number of local calendar days immediately **before +the query starts**, filtered with the same per-input conditions. It never uses +the current cohort. The center uses the selected summary function; its range is +center ± threshold × population standard deviation of individual matched +inputs. This is a descriptive range, not a confidence interval or a diagnostic +threshold. At least ten usable baseline inputs, and nonzero variation, are +required. Totals, counts and standard deviations do not receive baseline bands. + +`above`, `below` and `outside` are supported. Flags require the minimum usable +inputs in **each** consecutive calendar bucket; missing, insufficient, in-range +or opposite-direction buckets reset the streak. Only the bucket completing the +streak and following qualifying buckets are marked. The grammar uses `runs` for +the minimum count even for laps/segments; the displayed coverage uses the actual +source. Segments from one run are correlated observations, so a high segment +count does not imply many independent sessions. Calendar grouping is required; +trend transforms and group removal/ranking cannot be combined with flags. +Terrain, conditions, warm-up and sensor differences can still affect matched +effort. Inspect the actual inputs and workload before interpreting a departure. + +## Recording-level exploration + +```text +segments + during last 12 weeks + where pace between 5:20 and 5:40 /km + and pace_variability <= 5 % + and sensor_coverage >= 80 % + and duration >= 4 min + summarize weighted_mean(heart_rate) + by week + compare none + chart line +``` + +`segments` expands persisted five-minute **moving-time** segments; the final +partial segment is retained, so filter duration when complete segments matter. +The segments accumulate usable moving intervals across pauses/gaps rather than +claiming five uninterrupted minutes. Intervals require increasing timestamps +with at most ten seconds between recordings, positive plausible movement +(0.5–15 m/s), and increasing distance. Missing sensor values are not interpolated. +HR is duration weighted. Ascent requires elevation on at least 80% of usable +moving intervals; otherwise it is missing. Ascent is the sum of positive raw +altitude increments and can be sensitive to noise. Segment dates are the parent +run's local start date; selecting one opens that run. + +`laps` expands recorded lap distance, timer duration, HR and persisted GAP. Other +sensor/terrain metrics remain missing when the lap does not record them. Neither +source loads raw samples during a query. Their `count()` counts inputs; `by run` +gives each lap/segment its own row. Run-history metrics and custom rolling +calculations require `runs`. + +Additional recording-derived metrics: + +- `pace_variability`: duration-weighted **speed** coefficient of variation (%). +- `sensor_coverage`: usable moving time with HR / usable moving time (%), excluding gaps and pauses. +- `heart_rate_drift`: percent loss of speed/HR efficiency between moving-time halves. At least four moving minutes and ≥80% HR coverage in each half are required. Positive means lower efficiency later; warm-up and terrain are included. +- `gap`: modelled grade-adjusted pace in seconds/km, formatted as min/km. + +New imports calculate these; older activities require `backfill-analysis`. + +## Visualization and named series + +`chart auto|line|bar|area|histogram|box|heatmap|stacked|none` controls presentation. +`display overlay` combines series with an axis per unit; `display separate` is +the default. Summary labels use JSON double-quoted strings: + +```text +summarize sum(distance) as "Weekly distance", count() as "Sessions" +reference heart_rate = 150 bpm +plot pace vs heart_rate +color by elevation_per_km +size by distance +``` + +References require the selected raw metric and compatible units. Scatter color +and size require a scatter plot; missing encoding values use midpoint styling. +Correlation uses paired per-input values, not grouped summaries. + +Histograms bin **individual input values** using up to 30 equal-width bins; +the final bin includes its upper endpoint. Boxes show min, p25, median, p75 and +max per group, with no inferred outlier rule. Distribution charts show current +inputs, while comparison cards retain the previous cohort. Calendar heatmaps +require `by day` and retain every day. Missing data differs from a zero count. +Distribution/heatmap overlays are not supported. PNG download exports the plot; +CSV exports the raw underlying grouped or per-input values. + +## Named training cohorts + +```text +runs + during last 12 weeks + summarize sum(distance) + by week + compare none + cohort "Lower HR" where heart_rate <= 145 bpm + cohort "Higher HR" where heart_rate > 145 bpm + chart stacked +``` + +Up to four named numeric conditions partition inputs in declaration order: the +**first matching cohort wins**, avoiding double counting. Unmatched/missing +inputs appear in Other / missing. Stacking requires one additive summary +(sum/count, distance, duration, elevation or calories), with no comparison, +baseline or trend. Thresholds are user-selected; names do not infer physiological zones. + +## Custom rolling calculations and yearly comparisons + +```text +let hr_14d = rolling_mean(heart_rate, 14 days) +let km_10d = rolling_sum(distance, 10 days) +let hr_count = rolling_count(heart_rate, 14 days) +compare previous year +``` + +Rolling functions use complete owner-scoped run history before filters, include +the current run, and exclude the exact lower elapsed-time boundary. They accept +1–365 days and an earlier metric/calculation. Means are unweighted, sums exclude +missing inputs, and counts count usable inputs. Calendar-bucket rolling trends +remain separate operations. `compare previous year` uses the same local-date +interval one year earlier, clamping leap-day boundaries; time charts align by +elapsed days from each period's start. + Write queries in **Stats**, use **Ctrl+Space** for completion and **Ctrl/Cmd+Enter** to execute. **Format** produces a canonical query. Live validation uses the same parser as execution. Comments occupy their own lines starting with `#`. diff --git a/migrations/versions/f31a0c9286bd_saved_analyses.py b/migrations/versions/f31a0c9286bd_saved_analyses.py new file mode 100644 index 0000000..bf4004d --- /dev/null +++ b/migrations/versions/f31a0c9286bd_saved_analyses.py @@ -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") diff --git a/scripts/preview-fixture.py b/scripts/preview-fixture.py index da63687..ff6d806 100644 --- a/scripts/preview-fixture.py +++ b/scripts/preview-fixture.py @@ -12,7 +12,7 @@ from waitress import serve from app import create_app from app.display import persist_analysis from app.extensions import db -from app.models import Activity, User +from app.models import Activity, SavedAnalysis, User from app.records import persist_efforts app = create_app( @@ -33,14 +33,20 @@ with app.app_context(): ) db.session.add(user) db.session.flush() - for i in range(5): + analysis_preview = "--analysis" in sys.argv + for i in range(70 if analysis_preview else 5): + effort_hr = 157 + i % 3 if i < 8 else 141 + i % 5 samples = [ { "point": [51.507 + 0.007 * math.sin(n / 70), -0.14 + n / 25000], - "distance": n * 20, + "distance": n * (15 if analysis_preview else 20), "time": n * 5, - "speed": 2.8 + 0.3 * math.sin(n / 20), - "heart_rate": 140 + 12 * math.sin(n / 30), + "speed": 3 + 0.02 * math.sin(n / 20) + if analysis_preview + else 2.8 + 0.3 * math.sin(n / 20), + "heart_rate": effort_hr + math.sin(n / 30) + if analysis_preview + else 140 + 12 * math.sin(n / 30), "elevation": 10 + n / 30, } for n in range(300) @@ -55,12 +61,12 @@ with app.app_context(): "Easy Sunday", "Tempo session", "Evening loop", - ][i], - started_at=datetime.now(UTC) - timedelta(days=i * 5), - distance_m=6000 + i * 700, - duration_s=2100 + i * 200, - elevation_m=38 + i * 7, - avg_hr=145, + ][i % 5], + started_at=datetime.now(UTC) - timedelta(days=i * (2 if analysis_preview else 5)), + distance_m=6000 if analysis_preview else 6000 + i * 700, + duration_s=1980 if analysis_preview else 2100 + i * 200, + elevation_m=30 if analysis_preview else 38 + i * 7, + avg_hr=effort_hr if analysis_preview else 145, sport="running", visibility="friends", samples=samples, @@ -71,6 +77,30 @@ with app.app_context(): if isinstance(row, Activity): persist_efforts(row) persist_analysis(row) + if analysis_preview: + from app.stats_presets import PRESETS + + names = [ + "HR at comparable pace", + "Workload context", + "HR in steady segments", + "Easy and harder volume", + ] + for position, name in enumerate(names): + preset = next(preset for preset in PRESETS if preset["name"] == name) + db.session.add( + SavedAnalysis( + owner_id=user.id, + name=name, + collection="Effort investigation", + query=preset["query"], + pinned=True, + position=position, + width="half", + view="chart", + notes="Synthetic preview data.", + ) + ) db.session.commit() # Test-only auto-login, isolated to this ephemeral process/database. diff --git a/tests/test_analysis_workspace.py b/tests/test_analysis_workspace.py new file mode 100644 index 0000000..0c57ff7 --- /dev/null +++ b/tests/test_analysis_workspace.py @@ -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) diff --git a/tests/test_app.py b/tests/test_app.py index 0388e0d..7d16d46 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -389,7 +389,9 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app): row = db.session.get(Activity, row_id) assert row.efforts_version == EFFORTS_VERSION assert row.best_efforts[0]["seconds"] == 300 - assert row.analysis_version == 1 + from app.display import ANALYSIS_VERSION + + assert row.analysis_version == ANALYSIS_VERSION assert row.analysis_summary["summary"]["samples"] == 2 assert activity_from_fit(1, "test:new", [doc]) is None doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z" diff --git a/tests/test_display.py b/tests/test_display.py index 8d3b585..68d21ab 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -38,7 +38,12 @@ def test_persisted_summaries_equal_full_resolution_and_raw_data_is_untouched(): expected = analyse(row) persist_analysis(row) assert row.analysis_version == ANALYSIS_VERSION - assert row.analysis_summary == {key: expected[key] for key in ("summary", "terrain", "laps")} + for key in ("terrain", "laps"): + assert row.analysis_summary[key] == expected[key] + for key, value in expected["summary"].items(): + assert row.analysis_summary["summary"][key] == value + assert row.analysis_summary["segments"] + assert "heart_rate_drift" in row.analysis_summary["summary"] assert row.samples == original assert len(row.display_data["chart"]) == CHART_LIMIT assert row.display_data["chart"][0]["distance"] == 0