From 418e03583ad98b59d9c428715c1a1fa9b0f2949f Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sat, 3 Oct 2026 13:22:32 +1000 Subject: [PATCH] Add matched training signals, daily recovery and performance analysis --- app/__init__.py | 6 + app/analysis_engine.py | 88 ++++- app/display.py | 4 +- app/models.py | 16 + app/recording_windows.py | 94 ++++++ app/recovery.py | 71 ++++ app/route_benchmarks.py | 48 +++ app/routes.py | 7 +- app/static/analysis-charts.js | 18 +- app/static/analysis-library.js | 5 +- app/static/live-analyses.js | 5 +- app/static/stats-editor.js | 9 + app/static/stats.js | 3 +- app/static/style.css | 3 + app/stats.py | 9 + app/stats_analysis.py | 12 +- app/stats_features.py | 34 +- app/stats_insights.py | 4 +- app/stats_presets.py | 10 +- app/stats_sources.py | 15 +- app/sync.py | 45 +++ app/templates/_analysis_workspace.html | 12 +- app/templates/_session_context.html | 6 + app/templates/_training_settings.html | 8 + app/templates/activity.html | 1 + app/templates/settings.html | 1 + app/templates/stats.html | 16 +- app/training.py | 54 ++++ app/training_metrics.py | 47 +++ app/training_signals.py | 111 +++++++ app/training_sources.py | 155 +++++++++ docs/DEPLOYMENT.md | 18 ++ docs/STATS_DSL.md | 112 +++++++ .../versions/7c6e32b491ad_training_signals.py | 41 +++ tests/test_analysis_workspace.py | 2 +- tests/test_training_signals.py | 306 ++++++++++++++++++ tests/test_web_sync.py | 31 +- 37 files changed, 1380 insertions(+), 47 deletions(-) create mode 100644 app/recording_windows.py create mode 100644 app/recovery.py create mode 100644 app/route_benchmarks.py create mode 100644 app/templates/_session_context.html create mode 100644 app/templates/_training_settings.html create mode 100644 app/training.py create mode 100644 app/training_metrics.py create mode 100644 app/training_signals.py create mode 100644 app/training_sources.py create mode 100644 migrations/versions/7c6e32b491ad_training_signals.py create mode 100644 tests/test_training_signals.py diff --git a/app/__init__.py b/app/__init__.py index b0037d4..7e66ee9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -72,6 +72,9 @@ def create_app(config=None): from .analysis_library import library app.register_blueprint(library) + from .training import training + + app.register_blueprint(training) from .planner import planner app.register_blueprint(planner) @@ -84,6 +87,9 @@ def create_app(config=None): from .display import register_cli as register_display_cli register_display_cli(app) + from .training_signals import register_cli as register_training_cli + + register_training_cli(app) @login_manager.user_loader def load_user(user_id): diff --git a/app/analysis_engine.py b/app/analysis_engine.py index a20ebbe..7471939 100644 --- a/app/analysis_engine.py +++ b/app/analysis_engine.py @@ -7,7 +7,7 @@ from types import SimpleNamespace from zoneinfo import ZoneInfo from .extensions import db -from .models import Activity +from .models import Activity, DailyHealth, User from .stats import METRICS, format_value, metric_value, period_bounds from .stats import aggregate as legacy_aggregate from .stats_analysis import describe, evaluate, expression_tree, statistic, valid @@ -16,6 +16,12 @@ from .stats_dsl import QueryError def catalog_for(q): catalog = dict(METRICS) + if q["source"] in {"days", "health"}: + for days in (7, 28): + for field in ("distance", "count"): + key = f"rolling_{days}d_{field}" + label, unit, _ = catalog[key] + catalog[key] = (label, unit, f"Complete recorded running {field} over this local day and the preceding {days - 1} local days, before filters. Rest days are included.") for spec in q["derived"]: catalog[spec["name"]] = ( spec["name"].replace("_", " ").capitalize(), @@ -37,6 +43,9 @@ def summarize(rows, key, q): return len(rows), len(rows) values = [value(row, field) for row in rows] coverage = sum(valid(number) for number in values) + if function == "latest": + last = max(rows, key=lambda r: (r.instant, r.id)) if rows else None + return (value(last, field) if last else None), coverage if function == "weighted_mean": pairs = [ (value(row, field), row.distance_m if field == "pace" else row.duration_s) @@ -71,7 +80,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 in {"pace", "gap"}: + if raw in {"pace", "gap"} or raw == "effort_time" or raw.startswith("best_"): return format_value(number, "pace") if key == "count" or (spec and spec["function"] == "count"): return f"{number:,.0f}" @@ -158,6 +167,12 @@ def prepare(raw, zone, q, catalog): calories=(row.metrics or {}).get("total_calories"), training_effect=(row.metrics or {}).get("total_training_effect"), ) + row.values.update({key: (row.session_context or {}).get(key) + for key in ("perceived_effort", "fatigue")}) + for km in (1, 2, 5, 10): + efforts = [e.get("seconds") for e in row.best_efforts or [] + if e.get("distance") == km * 1000 and valid(e.get("seconds"))] + row.values[f"best_{km}km"] = min(efforts) if efforts else None for field in ( "pace", "heart_rate", @@ -210,6 +225,8 @@ def prepare(raw, zone, q, catalog): def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, raw_group=False): + from .training_sources import evidence as evidence_coverage + zone, catalog = ZoneInfo(timezone), catalog_for(q) start, end, prior_start = period_bounds(q, timezone) prior_end = start @@ -235,6 +252,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r Activity.avg_hr, Activity.analysis_summary, Activity.metrics, + Activity.best_efforts, + Activity.session_context, + *([Activity.route_geometry] if q["route"] else []), ) .where( Activity.owner_id == owner_id, @@ -243,7 +263,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r ) .order_by(Activity.started_at, Activity.id) ) - if q["source"] != "runs" and not q["all_time"]: + if q["source"] not in {"runs", "days", "health"} and not q["all_time"]: lower = min( start, prior_start if q["compare"] else start, @@ -252,14 +272,47 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r 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 + user = db.session.get(User, owner_id) + if user and user.hr_bands: + for row in history: + intervals = (row.analysis_summary or {}).get("intervals", []) + usable = any(unit.get("hr_seconds") for unit in intervals) + for name in ("lower", "middle", "upper"): + row.values[name + "_hr_minutes"] = 0.0 if usable else None + for unit in intervals: + for hr, seconds in unit.get("hr_seconds", {}).items(): + hr = float(hr) + name = "lower" if hr < user.hr_bands[0] else "middle" if hr < user.hr_bands[1] else "upper" + row.values[name + "_hr_minutes"] += seconds / 60 + if q["route"]: + from .route_benchmarks import similar - history = expand(history, q, catalog) + reference = db.session.scalar(db.select(Activity.route_geometry).where( + Activity.id == q["route"], Activity.owner_id == owner_id)) + if reference is None: + raise QueryError("Choose one of your own recorded routes.") + history = [row for row in history if similar(reference, row.route_geometry)] if q["all_time"] and history: start = history[0].day prior_start = start + if q["all_time"] and q["source"] in {"days", "health"}: + first_health = db.session.scalar(db.select(db.func.min(DailyHealth.day)).where( + DailyHealth.owner_id == owner_id, DailyHealth.day < end)) + if first_health: + start = min(start, first_health) if history else first_health + prior_start = start + if q["source"] in {"days", "health"}: + from .training_sources import daily + lower = min(start, prior_start if q["compare"] else start, + start - timedelta(days=q["baseline"] or 0)) + lower -= timedelta(days=max((spec.get("window", {}).get("days", 0) + for spec in q["derived"]), default=0)) + history = daily(history, owner_id, zone, lower, end, q, catalog) + elif q["source"] != "runs": + from .stats_sources import expand + + history = expand(history, q, catalog) def included(row): return ( row.distance_m >= q["minimum"] * 1000 @@ -270,6 +323,12 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r and (not q["filter_groups"] or any(matches(row, group) for group in q["filter_groups"])) ) + if q["reduce"]: + from .training_sources import reduce_runs + + history = reduce_runs([row for row in history if included(row)], q["reduce"], catalog) + included = lambda row: True + current = ordered( [row for row in history if start <= row.day < end and included(row)], q["sort"] ) @@ -417,8 +476,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r number, coverage = summarize(current, key, q) before, _ = summarize(prior, key, q) title, unit, definition = descriptors[key] - if unit == "runs": - unit = q["source"] + if unit == "runs" and key != "sessions" and not key.startswith("rolling_"): + unit = "runs" if q["reduce"] else q["source"] cards.append( { "key": key, @@ -480,8 +539,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"] + if unit == "runs" and key != "sessions" and not key.startswith("rolling_"): + unit = "runs" if q["reduce"] else q["source"] pace_axis = unit == "/km" if q["trend"] == "percent_change": unit, pace_axis = "%", False @@ -507,6 +566,10 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r "reference": q["reference"], "dates": [token.isoformat() for token in tokens] if group == "day" else [], "source": q["source"], + "timezone": timezone, + "timestamps": [next(row.instant.timestamp() * 1000 for row in current if row.id == token) + for token in tokens] if group == "run" else [], + "coverage": [evidence_coverage(current_groups[token], q["source"]) for token in tokens], } if q["cohorts"]: cohorts = [{"label": cohort["name"], "values": []} for cohort in q["cohorts"]] @@ -613,6 +676,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r "distance": f"{row.distance_m / 1000:.2f}", "values": [formatted(value(row, field), field, q) for field in inputs], "raw_values": [value(row, field) for field in inputs], + "activity_ids": getattr(row, "activity_ids", []), "previous_run": { "id": row.previous.id, "title": row.previous.title, @@ -624,7 +688,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r return { "cards": cards, - "source": q["source"], + "source": "matched runs" if q["reduce"] else q["source"], + "coverage": evidence_coverage(current, q["source"]), + "group_coverage": [evidence_coverage(current_groups[token], q["source"]) for token in tokens], "chart": chart, "insights": insights, "groups": groups, diff --git a/app/display.py b/app/display.py index 5f6b4eb..70d0207 100644 --- a/app/display.py +++ b/app/display.py @@ -7,7 +7,7 @@ import click from .analytics import analyse, numeric -ANALYSIS_VERSION = 2 +ANALYSIS_VERSION = 3 CHART_LIMIT = 750 ROUTE_TARGET = 1400 CHART_KEYS = ( @@ -105,6 +105,7 @@ def compact_routes(samples): def persist_analysis(activity): from .effort_segments import analyze + from .recording_windows import analyze as continuous_windows analysis = analyse(activity) effort, segments = analyze(activity.samples or []) @@ -113,6 +114,7 @@ def persist_analysis(activity): ) activity.analysis_summary = {key: analysis[key] for key in ("summary", "terrain", "laps")} activity.analysis_summary["segments"] = segments + activity.analysis_summary["intervals"] = continuous_windows(activity.samples or []) if activity.sport == "running" else [] 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/models.py b/app/models.py index e0aac42..ce5e44d 100644 --- a/app/models.py +++ b/app/models.py @@ -16,9 +16,11 @@ class User(UserMixin, db.Model): # Assigned only by local setup / operator CLI. Never accepted from registration input. local_account_key = db.Column(db.String(64), unique=True) created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC)) + hr_bands = db.Column(db.JSON) activities = db.relationship( "Activity", backref="owner", lazy="select", cascade="all, delete-orphan" ) + daily_health = db.relationship("DailyHealth", cascade="all, delete-orphan") def set_password(self, password): self.password_hash = generate_password_hash(password) @@ -38,6 +40,9 @@ class GarminConnection(db.Model): last_success = db.Column(db.DateTime(timezone=True)) message = db.Column(db.String(200), nullable=False, default="Sync queued.") failures = db.Column(db.Integer, nullable=False, default=0) + health_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.false()) + health_message = db.Column(db.String(200), nullable=False, default="Daily recovery sync is off.", + server_default="Daily recovery sync is off.") class Activity(db.Model): @@ -51,6 +56,7 @@ class Activity(db.Model): duration_s = db.Column(db.Float, nullable=False, default=0) elevation_m = db.Column(db.Float, nullable=False, default=0) avg_hr = db.Column(db.Float) + session_context = db.Column(db.JSON, nullable=False, default=dict, server_default="{}") metrics = db.Column(db.JSON, nullable=False, default=dict, server_default="{}") visibility = db.Column(db.String(12), nullable=False, default="private") share_token = db.Column(db.String(64), unique=True) @@ -123,6 +129,16 @@ class SavedAnalysis(db.Model): ) +class DailyHealth(db.Model): + id = db.Column(db.Integer, primary_key=True) + owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) + day = db.Column(db.Date, nullable=False) + values = db.Column(db.JSON, nullable=False, default=dict) + fetched_at = db.Column(db.DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(UTC)) + __table_args__ = (db.UniqueConstraint("owner_id", "day"),) + + 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/recording_windows.py b/app/recording_windows.py new file mode 100644 index 0000000..15c49bc --- /dev/null +++ b/app/recording_windows.py @@ -0,0 +1,94 @@ +"""Non-overlapping continuous 30-second windows, persisted during import/backfill.""" + +import math +from itertools import pairwise + +from .analytics import enrich, numeric, running_cost + +SENSORS = ("heart_rate", "cadence", "power", "stride_length", "ground_contact", + "vertical_oscillation") + + +def pack(intervals, offset, moving_offset): + duration = sum(i["dt"] for i in intervals) + distance = sum(i["ds"] for i in intervals) + speed = distance / duration + result = {"duration": duration, "distance": distance, "elapsed": offset / 60, + "moving_offset": moving_offset / 60} + for field in SENSORS: + usable = [i for i in intervals if numeric(i.get(field)) and i[field] > 0] + time = sum(i["dt"] for i in usable) + result[field] = sum(i[field] * i["dt"] for i in usable) / time if time else None + result[field + "_coverage"] = time / duration * 100 + result["sensor_coverage"] = result["heart_rate_coverage"] + hr_seconds = {} + for item in intervals: + if numeric(item.get("heart_rate")) and item["heart_rate"] > 0: + key = str(round(item["heart_rate"])) + hr_seconds[key] = hr_seconds.get(key, 0) + item["dt"] + result["hr_seconds"] = hr_seconds + result["pace_variability"] = math.sqrt( + sum((i["ds"] / i["dt"] - speed) ** 2 * i["dt"] for i in intervals) / duration + ) / speed * 100 + grades = [i for i in intervals if numeric(i.get("grade"))] + time = sum(i["dt"] for i in grades) + coverage = time / duration * 100 + result["terrain_coverage"] = coverage + joint = [i for i in grades if numeric(i.get("heart_rate")) and i["heart_rate"] > 0] + joint_time = sum(i["dt"] for i in joint) + result["joint_coverage"] = joint_time / duration * 100 + result["joint_hr"] = sum(i["heart_rate"] * i["dt"] for i in joint) / joint_time if joint_time else None + result["joint_speed"] = sum(i["ds"] for i in joint) / joint_time if joint_time else None + result["joint_grade"] = sum(i["grade"] * i["dt"] for i in joint) / joint_time if joint_time else None + mean = sum(i["grade"] * i["dt"] for i in grades) / time if time else None + result["grade"] = mean if coverage >= 80 else None + result["grade_variability"] = math.sqrt( + sum((i["grade"] - mean) ** 2 * i["dt"] for i in grades) / time + ) if coverage >= 80 else None + equivalent = sum(i["ds"] * running_cost(i["grade"] / 100) / running_cost(0) + for i in grades) + result["gap"] = 1000 * time / equivalent if coverage >= 80 and equivalent > 0 else None + result["hr_artifact_percent"] = sum(i["dt"] for i in intervals if i["hr_artifact"]) / duration * 100 + return result + + +def analyze(samples): + rows = enrich(samples) + origin = next((r["time"] for r in rows if numeric(r.get("time"))), 0) + result, pending, elapsed, moving = [], [], 0.0, 0.0 + offset = moving_start = 0.0 + + def flush(): + if pending: + result.append(pack(pending, offset, moving_start)) + + for before, after in pairwise(rows): + valid = all(numeric(r.get(k)) for r in (before, after) for k in ("time", "distance")) + dt = after["time"] - before["time"] if valid else 0 + ds = after["distance"] - before["distance"] if valid else 0 + if not (0 < dt <= 10 and 0.5 <= ds / dt <= 15): + flush() + pending, elapsed = [], 0.0 + continue + hr, previous_hr = after.get("heart_rate"), before.get("heart_rate") + artifact = numeric(hr) and (not 30 <= hr <= 240 or + numeric(previous_hr) and abs(hr - previous_hr) / dt > 20) + interval = {k: after.get(k) for k in SENSORS} + interval.update(grade=after.get("grade"), hr_artifact=bool(artifact)) + if artifact: + interval["heart_rate"] = None + remaining = dt + while remaining > 1e-9: + if not pending: + offset = before["time"] - origin + dt - remaining + moving_start = moving + portion = min(remaining, 30 - elapsed) + pending.append({**interval, "dt": portion, "ds": ds * portion / dt}) + remaining -= portion + elapsed += portion + moving += portion + if elapsed >= 30 - 1e-9: + flush() + pending, elapsed = [], 0.0 + flush() + return result diff --git a/app/recovery.py b/app/recovery.py new file mode 100644 index 0000000..80038aa --- /dev/null +++ b/app/recovery.py @@ -0,0 +1,71 @@ +"""Bounded optional daily Garmin imports; provider failures never disclose sessions.""" + +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from .analytics import numeric +from .extensions import db +from .models import DailyHealth + + +def normalize(stats, hrv, sleep): + summary = (hrv or {}).get("hrvSummary") or {} + dto = (sleep or {}).get("dailySleepDTO") or {} + candidates = {"resting_hr": (stats or {}).get("restingHeartRate"), + "hrv": summary.get("lastNightAvg"), + "sleep_hours": dto.get("sleepTimeSeconds"), + "stress": (stats or {}).get("averageStressLevel")} + values = {} + for key, number in candidates.items(): + if not numeric(number): + continue + if key == "sleep_hours": + number /= 3600 + low, high = {"resting_hr": (25, 220), "hrv": (1, 500), + "sleep_hours": (0, 24), "stress": (0, 100)}[key] + if low <= number <= high: + values[key] = number + return values + + +def sync_health(api, user, days=90, limit=5): + today = datetime.now(UTC).astimezone(ZoneInfo(user.timezone)).date() + first = today - timedelta(days=days - 1) + known = {row.day: row for row in db.session.scalars(db.select(DailyHealth).where( + DailyHealth.owner_id == user.id, DailyHealth.day >= first))} + # Refresh today/yesterday; then walk backwards through missing history. + dates = [today, today - timedelta(days=1)] + dates += [today - timedelta(days=i) for i in range(2, days) + if today - timedelta(days=i) not in known or + not known[today - timedelta(days=i)].values.get("_complete")] + dates = [day for day in dates if day >= first][:limit] + imported = failures = 0 + for day in dates: + documents = [] + day_failures = 0 + for method in ("get_stats", "get_hrv_data", "get_sleep_data"): + try: + document = getattr(api, method)(day.isoformat()) + documents.append(document if isinstance(document, dict) else {}) + except Exception: # noqa: BLE001 -- never log provider responses or credentials + documents.append({}) + failures += 1 + day_failures += 1 + values = normalize(*documents) + if day_failures and not values: + # Retry later; stop the batch instead of hammering a failing provider. + break + row = known.get(day) + if not row: + row = DailyHealth(owner_id=user.id, day=day, values={}) + db.session.add(row) + row.values = {**row.values, **values, "_complete": day_failures == 0} + row.fetched_at = datetime.now(UTC) + db.session.commit() + imported += 1 + if day_failures: + break + return {"days": imported, "failures": failures, + "message": f"Daily recovery: {imported} dates checked." + + (" Some observations unavailable; will retry." if failures else + " Missing watch observations remain missing.")} diff --git a/app/route_benchmarks.py b/app/route_benchmarks.py new file mode 100644 index 0000000..b2723e6 --- /dev/null +++ b/app/route_benchmarks.py @@ -0,0 +1,48 @@ +"""Approximate same-start, same-direction full-course matching, without raw samples.""" + +import math +from itertools import pairwise + +from .analytics import numeric + + +def distance(a, b): + lat1, lat2 = math.radians(a[0]), math.radians(b[0]) + dlat, dlon = lat2 - lat1, math.radians(b[1] - a[1]) + h = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + return 12742000 * math.asin(math.sqrt(min(1, h))) + + +def resample(segments): + # A recording break must not invent a connecting route. + if not isinstance(segments, list) or len(segments) != 1: + return None + points = segments[0] + if len(points) < 2 or any(not isinstance(p, list) or len(p) != 2 or + not all(numeric(v) for v in p) or + not -90 <= p[0] <= 90 or not -180 <= p[1] <= 180 for p in points): + return None + cumulative = [0.0] + for a, b in pairwise(points): + cumulative.append(cumulative[-1] + distance(a, b)) + total = cumulative[-1] + if total < 500: + return None + result, cursor = [], 0 + for i in range(33): + target = total * i / 32 + while cursor < len(points) - 2 and cumulative[cursor + 1] < target: + cursor += 1 + span = cumulative[cursor + 1] - cumulative[cursor] + fraction = (target - cumulative[cursor]) / span if span else 0 + result.append([a + (b - a) * fraction for a, b in zip(points[cursor], points[cursor + 1], strict=True)]) + return total, result + + +def similar(reference, candidate): + a, b = resample(reference), resample(candidate) + if not a or not b or abs(b[0] / a[0] - 1) > .1: + return False + differences = sorted(distance(x, y) for x, y in zip(a[1], b[1], strict=True)) + return (distance(a[1][0], b[1][0]) <= 100 and distance(a[1][-1], b[1][-1]) <= 100 + and sum(differences) / len(differences) <= 100 and differences[29] <= 200) diff --git a/app/routes.py b/app/routes.py index 2c2a8d0..377550c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -550,7 +550,7 @@ def garmin_action(action): from .sync import connect, session_lock - if action not in {"connect", "sync", "schedule", "disconnect"}: + if action not in {"connect", "sync", "schedule", "disconnect", "health"}: abort(404) try: if action == "connect": @@ -572,6 +572,11 @@ def garmin_action(action): elif action == "schedule": connection.scheduled = request.form.get("enabled") == "yes" connection.next_sync = datetime.now(UTC) + elif action == "health": + connection.health_enabled = request.form.get("enabled") == "yes" + connection.health_message = "Daily recovery sync queued." if connection.health_enabled else "Daily recovery sync is off." + if connection.health_enabled: + connection.pending = True else: db.session.delete(connection) current_user.local_account_key = None diff --git a/app/static/analysis-charts.js b/app/static/analysis-charts.js index 1f05cf3..b3e005a 100644 --- a/app/static/analysis-charts.js +++ b/app/static/analysis-charts.js @@ -2,8 +2,9 @@ (() => { 'use strict'; const colors = ['#f48459', '#8ccbb4', '#9cb8e8', '#d2ace8', '#e0c375', '#75cad6']; + const unitLabel = unit => unit==='time'?'min:sec':unit; 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); + const number = (value, unit) => value == null ? '—' : ['/km','time'].includes(unit) ? 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; } @@ -28,8 +29,8 @@ 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 chart = (canvas, config) => {if(data.coverage&&!data.scatter){const plugins=config.options.plugins||(config.options.plugins={});const tooltip=plugins.tooltip||(plugins.tooltip={});const callbacks=tooltip.callbacks||(tooltip.callbacks={});callbacks.afterBody=items=>{const coverage=data.coverage[items[0]?.dataIndex];return coverage?`${coverage.sessions} independent sessions · ${coverage.minutes} usable minutes`:'';};}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=unitLabel(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'); @@ -64,13 +65,16 @@ 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))]; + const canvas=frame(seriesGroup.map(s=>`${s.label} (${unitLabel(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; + const timeAxis=data.group==='run'&&data.timestamps?.length&&['line','area'].includes(data.type); + const values=timeAxis?series.values.map((y,i)=>({x:data.timestamps[i],y})):series.values; + if(timeAxis)scales.x={type:'linear',min:Math.min(...data.timestamps)-(data.timestamps.length===1?43200000:0),max:Math.max(...data.timestamps)+(data.timestamps.length===1?43200000:0),ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7,callback:v=>new Intl.DateTimeFormat(undefined,{timeZone:data.timezone||'UTC',month:'short',day:'numeric'}).format(new Date(v))},grid:{display:false}}; 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(data.reference?.field===series.key)datasets.push({label:'Reference',data:data.labels.map((_,i)=>timeAxis?{x:data.timestamps[i],y:data.reference.value}:data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true}); + datasets.push({label:series.label,data: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]);}}}); @@ -82,7 +86,7 @@ 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}`);}}}); + 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){const id=c.data.datasets[points[0].datasetIndex].data[points[0].index].id;if(id)location.assign(`/activities/${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());}; diff --git a/app/static/analysis-library.js b/app/static/analysis-library.js index c516fc6..8741dbe 100644 --- a/app/static/analysis-library.js +++ b/app/static/analysis-library.js @@ -48,7 +48,10 @@ 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`; + const windows=fields.source==='intervals'; + const filters=windows?` reduce by run min ${fields.matched} min\n where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elapsed >= ${fields.warmup} min\n and grade between -${fields.grade} and ${fields.grade} %\n and grade_variability <= 2 %\n and pace_variability <= 15 %\n and sensor_coverage >= 90 %\n and duration >= 25 s\n and hr_artifact_percent <= 0 %\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`; + const baseline=fields.use_baseline?` baseline previous ${fields.baseline} days\n flag above baseline 1.5 sd min ${fields.minimum} runs sustained 2 buckets\n`:''; + const text=`${fields.source}\n during last ${fields.weeks} weeks\n${filters} summarize ${windows?'mean':'weighted_mean'}(heart_rate) as "Matched-pace HR"\n by ${fields.use_baseline?'week':'run'}\n sort by date asc\n compare none\n${baseline} chart line`; location.assign('/stats?'+new URLSearchParams({query:text})); }; document.querySelectorAll('[data-presentation]').forEach(control=>control.onchange=()=>{ diff --git a/app/static/live-analyses.js b/app/static/live-analyses.js index 85e6f40..431396d 100644 --- a/app/static/live-analyses.js +++ b/app/static/live-analyses.js @@ -11,7 +11,7 @@ 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.')); + if(!data.count)body.append(el('p','field-help','No observations 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); @@ -19,7 +19,8 @@ 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'})}`; + const coverage=data.coverage; + widget.querySelector('.widget-status').textContent=`${data.count} qualifying ${data.source==='matched runs'?'runs':data.source}${coverage?` · ${coverage.sessions} independent sessions · ${coverage.minutes} usable minutes`:''} · ${data.start} – ${data.end} · updated ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`; states.set(id,{result,dispose}); } async function refresh(){ diff --git a/app/static/stats-editor.js b/app/static/stats-editor.js index e0c5b7d..38ce38f 100644 --- a/app/static/stats-editor.js +++ b/app/static/stats-editor.js @@ -26,6 +26,15 @@ ['runs', 'Your recorded running activities'], ['laps', 'Recorded lap inputs from compact analysis'], ['segments', 'Five-minute moving segments from full-resolution recordings'], + ['intervals', 'Continuous 30-second moving windows, with elapsed offset and terrain'], + ['efforts', 'Recorded best-effort windows at 1, 2, 5 and 10 km'], + ['days', 'Daily workload and rest days, using local calendar windows'], + ['health', 'Private daily recovery observations from Garmin'], + ['reduce by run min 3 min', 'Match recording windows first; one observation per qualifying session'], + ['long run distance >= 10 km', 'Choose the qualifying long-run threshold for daily continuity'], + ['route similar to 1', 'Approximate full-course match to one of your own recorded routes'], + ['where elapsed >= 10 min', 'Exclude the first ten elapsed recording minutes'], + ['and grade between -2 and 2 %', 'Require approximately flat smoothed terrain'], ['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'], diff --git a/app/static/stats.js b/app/static/stats.js index 323fdc5..612f597 100644 --- a/app/static/stats.js +++ b/app/static/stats.js @@ -30,8 +30,9 @@ tbody.replaceChildren(); result.rows.forEach(row => { const tr = document.createElement('tr'); const td = document.createElement('td'); - const a = document.createElement('a'); a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`; + const a = document.createElement(row.id?'a':'span'); if(row.id)a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`; td.append(a); tr.append(td); + (row.activity_ids||[]).forEach(id=>{const link=document.createElement('a');link.href=`/activities/${id}`;link.textContent=` · Run #${id}`;td.append(link);}); if (root.dataset.showPrevious === 'true') { const detail=document.createElement('small');detail.className='previous-run'; detail.append('Previous: '); diff --git a/app/static/style.css b/app/static/style.css index af294e6..0a40238 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -48,3 +48,6 @@ a.record-badge:focus-visible { outline: 2px solid #f3cb78; outline-offset: 3px; @media(max-width:680px){.app-header{height:auto;min-height:72px;padding:16px 18px;gap:12px;flex-wrap:wrap}.app-header .brand{font-size:25px;gap:8px}.app-header .brand-mark{width:28px;height:28px;font-size:25px}.header-nav{gap:0}.header-nav a{padding:8px;font-size:12px}.header-actions{gap:8px}.header-import{order:1;padding:8px 10px;font-size:11px}.header-import .icon{display:none}.app-header .brand-dot{margin-left:-6px}.account-dropdown-menu{width:220px}.app-shell .page{padding-top:25px}.page-heading h1{font-size:30px}} @media(max-width:680px){.header-import{display:none}} + +/* Keep visually hidden table labels within the scroll container on mobile. */ +.activity-table-wrap{position:relative} diff --git a/app/stats.py b/app/stats.py index b2c7eca..10f271b 100644 --- a/app/stats.py +++ b/app/stats.py @@ -70,6 +70,9 @@ DEFAULT = { "reference": None, "source": "runs", "cohorts": [], + "reduce": None, + "long_run": 10.0, + "route": None, "comparison": "period", } @@ -132,6 +135,12 @@ def query_text(q): else: period = f"during last {q['weeks']} weeks" lines = [q["source"], " " + period] + if q["reduce"]: + lines.append(f" reduce by run min {number_text(q['reduce'])} min") + if q["long_run"] != 10: + lines.append(f" long run distance >= {number_text(q['long_run'])} km") + if q["route"]: + lines.append(f" route similar to {q['route']}") for spec in q["derived"]: lines.append(f" let {spec['name']} = {spec['expression']}") if q["end"] and not q["start"]: diff --git a/app/stats_analysis.py b/app/stats_analysis.py index 41bd5e2..8bf77fd 100644 --- a/app/stats_analysis.py +++ b/app/stats_analysis.py @@ -8,6 +8,7 @@ import statistics from .stats_dsl import UNITS, QueryError, parse_dsl FUNCTIONS = { + "latest", "sum", "mean", "weighted_mean", @@ -134,6 +135,13 @@ EXTRA_UNITS = { } +from .training_metrics import METRICS as TRAINING_METRICS +from .training_metrics import UNITS as TRAINING_UNITS + +EXTRA_METRICS.update(TRAINING_METRICS) +EXTRA_UNITS.update(TRAINING_UNITS) + + def valid(value): return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) @@ -261,7 +269,7 @@ def parse_analysis(text, metrics, defaults): function, field = match.groups() if function not in FUNCTIONS or (not field and function != "count"): raise QueryError( - "Use sum, mean, weighted_mean, median, min, max, stddev, count or p10/p25/p75/p90/p95/p99.", + "Use latest, sum, mean, weighted_mean, median, min, max, stddev, count or p10/p25/p75/p90/p95/p99.", line, ) if field and field not in catalog: @@ -531,6 +539,8 @@ def describe(key, catalog, aggregates): ) + ". Percentiles use linear interpolation; stddev is population standard deviation. Missing values are excluded." ) + if function == "latest": + definition = "Value on the latest input by local date/time, including a missing value on that input. Sorting does not change which input is latest." return label, unit, definition diff --git a/app/stats_features.py b/app/stats_features.py index 598749f..fcf580a 100644 --- a/app/stats_features.py +++ b/app/stats_features.py @@ -37,6 +37,9 @@ def extract(text): "source": "runs", "cohorts": [], "comparison": "period", + "reduce": None, + "long_run": 10.0, + "route": None, } seen, lines = set(), text.splitlines() @@ -47,11 +50,26 @@ def extract(text): for index, source in enumerate(lines): line, number = source.strip(), index + 1 - if line in {"runs", "laps", "segments"}: + if line in {"runs", "laps", "segments", "intervals", "efforts", "days", "health"}: once("source", number) options["source"] = line lines[index] = "runs" continue + elif match := re.fullmatch(r"reduce by run min (\d+(?:\.\d+)?) min", line): + once("reduce", number) + minutes = float(match[1]) + if not 1 <= minutes <= 180: + raise QueryError("Use 1–180 matched minutes per run.", number) + options["reduce"] = minutes + elif match := re.fullmatch(r"long run distance >= (\d+(?:\.\d+)?) km", line): + once("long_run", number) + distance = float(match[1]) + if not 1 <= distance <= 100: + raise QueryError("Use a long-run threshold of 1–100 km.", number) + options["long_run"] = distance + elif match := re.fullmatch(r"route similar to (\d+)", line): + once("route", number) + options["route"] = int(match[1]) elif line == "compare previous year": options["comparison"] = "year" lines[index] = "compare previous period" @@ -138,6 +156,16 @@ def extract(text): def validate(q, catalog): + if q["flag"] and q["source"] in {"laps", "segments", "intervals"} and not q["reduce"]: + raise QueryError("Reduce recordings to independent runs before using a deviation flag.") + if q["reduce"] and q["source"] not in {"intervals", "segments"}: + raise QueryError("reduce by run requires intervals or segments.") + if q["reduce"] and (q["derived"] or q["cohorts"]): + raise QueryError("Reduce matched recordings before adding calculations or cohorts.") + if q["route"] and q["source"] in {"days", "health"}: + raise QueryError("Route matching requires a recording or runs source.") + if q["source"] in {"days", "health"} and q["group"] in {"run", "hour"}: + raise QueryError("Daily observations require calendar, all, or numeric grouping.") if q["plot"] and q["chart"] == "none": raise QueryError("A scatter plot conflicts with chart none.") if q["cohorts"] and ( @@ -167,7 +195,7 @@ def validate(q, catalog): ) if q["compare"]: raise QueryError("Stacked cohorts show the current period; use compare none.") - if q["source"] != "runs": + if q["source"] not in {"runs", "days", "health"}: temporal = { "gap_since_previous_run", "distance_change", @@ -231,7 +259,7 @@ def validate(q, catalog): *q["extra_metrics"], ] supported = lambda key: ( - q["aggregates"][key]["function"] not in {"sum", "count", "stddev"} + q["aggregates"][key]["function"] not in {"sum", "count", "stddev", "latest"} if key in q["aggregates"] else key not in additive ) diff --git a/app/stats_insights.py b/app/stats_insights.py index 594fa5f..b7bf28c 100644 --- a/app/stats_insights.py +++ b/app/stats_insights.py @@ -56,7 +56,7 @@ def enrich(chart, q, keys, tokens, groups, history, included, start, measure, ca continue # Comparing totals/counts to a distribution of individual runs is meaningless. supported = ( - spec["function"] not in {"sum", "count", "stddev"} + spec["function"] not in {"sum", "count", "stddev", "latest"} if spec else field not in {"count", "distance", "duration", "elevation", "calories"} ) @@ -111,7 +111,7 @@ def enrich(chart, q, keys, tokens, groups, history, included, start, measure, ca "label": series["label"], "unit": series["unit"], "baseline": center, - "source": q["source"], + "source": "runs" if q["reduce"] else q["source"], "low": low, "high": high, "count": len(numbers), diff --git a/app/stats_presets.py b/app/stats_presets.py index 9a47119..edf136f 100644 --- a/app/stats_presets.py +++ b/app/stats_presets.py @@ -5,7 +5,7 @@ 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', + "query": 'segments\n during last 12 weeks\n reduce by run min 3 min\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", @@ -92,3 +92,11 @@ PRESETS = [ "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", }, ] + + +# Editable advanced examples. No account observations or inferred HR boundaries. +from .training_signals import signal_queries + +PRESETS.extend({"name": d["name"], "category": "Deep training signals", + "description": d["notes"], "query": d["query"]} + for d in signal_queries()) diff --git a/app/stats_sources.py b/app/stats_sources.py index 7877b02..30b9351 100644 --- a/app/stats_sources.py +++ b/app/stats_sources.py @@ -12,7 +12,12 @@ def expand(history, q, catalog): (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"], [])): + source = q["source"] + units = (activity.analysis_summary or {}).get(source, []) + if source == "efforts": + units = [{"distance": e["distance"], "duration": e["seconds"], + "effort_time": e["seconds"]} for e in activity.best_efforts or []] + for index, unit in enumerate(units): if ( not isinstance(unit, dict) or not valid(unit.get("distance")) @@ -23,6 +28,8 @@ def expand(history, q, catalog): continue row = copy(activity) row.activity_id = activity.id + row.parent_title = activity.title + row.recording = unit row.id = activity.id * 1000000 + index row.title = ( f"{activity.title} · {'lap' if q['source'] == 'laps' else 'segment'} {index + 1}" @@ -42,12 +49,16 @@ def expand(history, q, catalog): pace=row.duration_s * 1000 / row.distance_m, speed=row.distance_m / row.duration_s * 3.6, heart_rate=row.avg_hr, + matched_minutes=row.duration_s / 60, + matched_windows=1, 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"): + for key in catalog: + if key in {"distance", "duration", "pace", "count", "speed", "heart_rate", "elevation", "elevation_per_km", "matched_minutes", "matched_windows"}: + continue row.values[key] = unit.get(key) row.analysis_summary = row.values row.previous = None diff --git a/app/sync.py b/app/sync.py index 1dd8847..296726a 100644 --- a/app/sync.py +++ b/app/sync.py @@ -101,6 +101,19 @@ def run_connection(connection_id): count = import_cache(user, current_app.config["DATA_ROOT"]) if result["failed"]: raise SyncError("Some activities failed; sync will retry.") + db.session.commit() + if connection.health_enabled: + from .recovery import sync_health + + try: + health = sync_health(api, user) + connection.health_message = health["message"] + except Exception: # noqa: BLE001 -- optional provider data cannot block activities + db.session.rollback() + connection.health_message = "Daily recovery unavailable; will retry. Activity sync completed." + finally: + connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() + db.session.commit() connection.last_success = now connection.failures = 0 connection.message = f"Sync complete. {count} new activities imported." @@ -150,6 +163,38 @@ def run_due(): def register_cli(app): + @app.cli.command("backfill-health") + @click.option("--email", required=True) + @click.option("--days", default=90, type=click.IntRange(1, 365)) + def health_backfill(email, days): + from .recovery import sync_health + + logging.getLogger("garminconnect").disabled = True + user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower())) + connection = db.session.scalar(db.select(GarminConnection).filter_by( + user_id=user.id)) if user else None + if not connection: + raise click.ClickException("No connected account found.") + try: + with session_lock(user.id): + api = Garmin() + authenticated = False + try: + api.login(cipher().decrypt(connection.tokens.encode()).decode()) + authenticated = True + result = sync_health(api, user, days, days) + connection.health_enabled = True + connection.health_message = result["message"] + click.echo(result["message"]) + finally: + if authenticated: + connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() + db.session.commit() + except Timeout: + raise click.ClickException("A sync is running; retry after it finishes.") from None + except Exception: # noqa: BLE001 -- suppress provider credentials at the operator boundary + raise click.ClickException("Daily recovery import failed; retry or reconnect.") from None + @app.cli.command("sync-worker") @click.option("--once", is_flag=True, help="Process currently due syncs and exit.") def worker(once): diff --git a/app/templates/_analysis_workspace.html b/app/templates/_analysis_workspace.html index 9b0c6c6..162add1 100644 --- a/app/templates/_analysis_workspace.html +++ b/app/templates/_analysis_workspace.html @@ -18,15 +18,19 @@ 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.

+

Continuous windows require ≤15% speed variation, ≤2 percentage points of grade variation, ≥90% HR coverage and ≥25 seconds. Each qualifying run contributes one observation after matching. An earlier baseline needs at least ten independent runs; it may be unavailable for a short history.

diff --git a/app/templates/_session_context.html b/app/templates/_session_context.html new file mode 100644 index 0000000..201b382 --- /dev/null +++ b/app/templates/_session_context.html @@ -0,0 +1,6 @@ +{% if owner_view %}
PRIVATE SESSION CONTEXT

How did this run feel?

+
+ + + +

These observations stay private when you share a run. Numeric observations are available in Stats.

{% endif %} diff --git a/app/templates/_training_settings.html b/app/templates/_training_settings.html new file mode 100644 index 0000000..18c0059 --- /dev/null +++ b/app/templates/_training_settings.html @@ -0,0 +1,8 @@ +
EFFORT DISTRIBUTION

Your heart-rate bands

+

Set two boundaries for your lower, middle and upper effort bands. Use your own chosen thresholds; these labels do not infer physiological zones.

+
+ + +

Recorded HR at a boundary belongs to the higher band. Clear both fields to remove your bands. Stats exposes actual recorded minutes in each band.

+{% if connection %}
DAILY RECOVERY

Recovery observations

Import private resting HR, overnight HRV, sleep duration and daily stress from Garmin.

{{ connection.health_message }} Historical observations fill gradually during hourly sync. Missing watch observations remain missing.

+
{% endif %} diff --git a/app/templates/activity.html b/app/templates/activity.html index b0df473..7fd223a 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -48,5 +48,6 @@

Laps & splits

{{ analysis.laps|length }} recorded laps
{% for lap in analysis.laps %}{% else %}{% endfor %}
LAPDISTANCETIMEPACEGAP EST.HR
{{ loop.index }}{{ '%.2f'|format(lap.distance / 1000) }} km{{ lap.duration|duration }}{{ pace_text(lap.pace) }}{{ pace_text(lap.gap) }}{{ lap.heart_rate|int if lap.heart_rate else '—' }}
No laps recorded.
{% if owner_view %}
YOUR RUN, YOUR CHOICE

Sharing settings

Accepted friends see the full route and measurements of shared activities. Public link access is separate: anyone with a copied link can view this run, including its full route and measurements, until you disable the link.

{% endif %}
About these numbers Recorded data vs. estimates +

GAP is an estimate, not a Garmin or Strava value. We use the Minetti running energy-cost model to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.

Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.

Heart rate stays measured. A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.

Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Routes are simplified for display, preserving segment endpoints and GPS gaps. Map measurements and chart traces are sampled for display; computations use all available records.

+{% include '_session_context.html' %} {% endblock %} {% block scripts %}{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html index e90c077..72099d0 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -16,4 +16,5 @@

Disconnect deletes the server session and stops scheduled sync. It keeps imported activities and does not revoke other session copies.

+
{% include '_training_settings.html' %}
{% endblock %} diff --git a/app/templates/stats.html b/app/templates/stats.html index 54ae41b..d025b12 100644 --- a/app/templates/stats.html +++ b/app/templates/stats.html @@ -2,7 +2,7 @@ {% block title %}Stats studio · Stride{% endblock %} {% block head %}{% endblock %} {% block content %} -
+
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' %} @@ -11,7 +11,7 @@
Checking query...Ctrl / Cmd Enter to run
{% if error %}{% endif %}
-
{{ '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.
+
{{ query.source|capitalize }} · {{ 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
@@ -29,7 +29,7 @@ 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.

+summarize mean(heart_rate) as "Matched HR"

Sources: runs, laps, segments, intervals, efforts, days, health. 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 .
@@ -44,17 +44,17 @@ summarize mean(heart_rate) as "Matched HR"

Sources: r

{% if results %} -

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

+

{{ results.count }} matching {{ results.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 }} {{ 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 %}
+{% if results.coverage %}

Evidence: {{ results.coverage.sessions }} independent running sessions · {{ results.coverage.minutes }} recorded moving/timer minutes. Daily recovery observations are separate from running-session coverage.

{% 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 }} {{ results.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 %} -
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 %}
+
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{{ results.source|upper }}{{ card.label|upper }} · {{ card.unit }}
{{ group.count }}{{ value }}
+
BEHIND THE NUMBERS

Contributing {{ results.source }}

Export inputs
{% for card in results.evidence_columns %}{% endfor %}{% for row in results.rows %}{% for value in row['values'] %}{% endfor %}{% else %}{% endfor %}
{{ results.source|upper }} / LOCAL DATEDISTANCE{{ card.label|upper }} · {{ card.unit }}
{% if row.id %}{{ row.title }}
{{ row.date }}
{% else %}{{ row.title }}
{{ row.date }}{% for id in row.activity_ids %} · Run #{{ id }}{% endfor %}{% endif %}{% 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 %} -

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.

+

Sources include runs, laps, moving segments, continuous windows, best efforts and daily training/recovery observations. Filters select inputs before optional per-run reduction; summaries then apply per group. Recording windows from the same session are dependent. Daily zeros mean no recorded running, rather than no other training; missing recovery and sensor observations stay missing. Run-history calculations use complete history before filters. Empty sums remain missing; count is zero.

{% endblock %} {% block scripts %}{% endblock %} diff --git a/app/training.py b/app/training.py new file mode 100644 index 0000000..24e5ac1 --- /dev/null +++ b/app/training.py @@ -0,0 +1,54 @@ +"""Private training preferences and session observations.""" + +from flask import Blueprint, abort, flash, redirect, request, url_for +from flask_login import current_user, login_required + +from .extensions import db +from .models import Activity + +training = Blueprint("training", __name__) + + +@training.post("/training/bands") +@login_required +def bands(): + raw = [request.form.get("lower", "").strip(), request.form.get("upper", "").strip()] + if not any(raw): + current_user.hr_bands = None + else: + try: + low, high = [int(v) for v in raw] + except ValueError: + abort(400) + if not 30 <= low < high <= 240: + abort(400) + current_user.hr_bands = [low, high] + db.session.commit() + flash("Personal heart-rate bands saved.") + return redirect(url_for("web.settings")) + + +@training.post("/activities//context") +@login_required +def context(activity_id): + row = db.first_or_404(db.select(Activity).where(Activity.id == activity_id, + Activity.owner_id == current_user.id)) + values = {} + for field, low in (("perceived_effort", 1), ("fatigue", 0)): + raw = request.form.get(field, "").strip() + if raw: + try: + value = int(raw) + except ValueError: + abort(400) + if not low <= value <= 10: + abort(400) + values[field] = value + notes = request.form.get("notes", "").strip() + if len(notes) > 2000: + abort(400) + values["notes"] = notes + row.session_context = values + db.session.commit() + flash("Private session context saved.") + return redirect(url_for("web.activity", activity_id=row.id)) diff --git a/app/training_metrics.py b/app/training_metrics.py new file mode 100644 index 0000000..fbaed39 --- /dev/null +++ b/app/training_metrics.py @@ -0,0 +1,47 @@ +"""Explicit units for recording, performance and daily recovery observations.""" + +METRICS = {} +UNITS = {} + + +def metric(key, label, unit, definition, conversions=None): + METRICS[key] = (label, unit, definition) + UNITS[key] = conversions or {unit: 1} + + +for key, label in (("elapsed", "Elapsed recording offset"), + ("moving_offset", "Moving-time offset"), + ("matched_minutes", "Matched moving minutes")): + metric(key, label, "min", "Minutes since recording start, or usable matched moving time.", + {"min": 1, "s": 1 / 60, "h": 60}) +for key, label in (("grade", "Smoothed grade"), ("grade_variability", "Grade variation"), + ("terrain_coverage", "Terrain coverage"), + ("joint_coverage", "Joint HR and terrain coverage"), + ("hr_artifact_percent", "Suspect HR time"), + ("long_run_share", "Largest run share of 7-day volume")): + metric(key, label, "%", "Recording-derived percentage; missing observations stay missing.") +metric("matched_windows", "Matched windows", "windows", "Usable continuous recording windows.") +metric("sessions", "Sessions", "runs", "Number of recorded running sessions on this local day.") +metric("days_since_run", "Days since running", "days", "Local days since the latest recorded run.") +metric("days_since_long_run", "Days since long run", "days", + "Local days since a recorded run meeting the query's long-run distance threshold.") +metric("effort_time", "Best effort time", "time", "Elapsed recorded best-effort window, in seconds.", + {"s": 1, "min": 60}) +for km in (1, 2, 5, 10): + metric(f"best_{km}km", f"Best {km} km", "time", + "Fastest elapsed window within this run, not a standardized race result.", + {"s": 1, "min": 60}) +for key, label, unit in (("resting_hr", "Resting HR", "bpm"), + ("hrv", "Overnight HRV", "ms"), + ("sleep_hours", "Sleep", "h"), + ("stress", "Daily stress", "score"), + ("perceived_effort", "Perceived effort", "score"), + ("fatigue", "Reported fatigue", "score")): + metric(key, label, unit, "Private recorded daily observation or owner-entered session context; no diagnosis.") +for sensor, unit in (("cadence", "spm"), ("power", "W"), ("ground_contact", "ms"), + ("stride_length", "m"), ("vertical_oscillation", "cm")): + metric(sensor + "_coverage", sensor.replace("_", " ").title() + " coverage", "%", + "Percent of this continuous moving window with a usable positive sensor measurement.") +for band in ("lower", "middle", "upper"): + metric(band + "_hr_minutes", band.title() + " HR minutes", "min", + "Time in individually configured HR bands; unavailable until thresholds are set.") diff --git a/app/training_signals.py b/app/training_signals.py new file mode 100644 index 0000000..205d016 --- /dev/null +++ b/app/training_signals.py @@ -0,0 +1,111 @@ +"""Reviewable, editable training analyses; no inferred medical or HR-zone thresholds.""" + +import click + +from .extensions import db +from .models import Activity, SavedAnalysis, User +from .stats import explore, parse_query, query_text + + +def signal_queries(pace_low="6:30", pace_high="7:00", long_run=10, route_id=None): + match = (f"where pace between {pace_low} and {pace_high} /km\n" + "and elapsed >= 10 min\nand duration >= 25 s\n" + "and grade between -2 and 2 %\nand grade_variability <= 2 %\n" + "and pace_variability <= 15 %\nand sensor_coverage >= 90 %\n" + "and hr_artifact_percent <= 0 %") + common = "during last 16 weeks\n" + definitions = [ + ("HR at matched flat effort", "intervals\n" + common + + "reduce by run min 3 min\n" + match + + '\nsummarize mean(heart_rate) as "Matched HR"\nby run\nsort by date asc\ncompare none\nchart line', + "full", "After ten elapsed minutes; matched continuous windows with flat, consistent terrain and speed. At least three matched minutes per session. Each run contributes one observation. HR lag, weather and session intent remain possible explanations; this is not a diagnosis."), + ("Training rhythm", "runs\n" + common + + 'summarize sum(distance) as "Weekly km", count() as "Sessions"\nby week\ncompare none\nchart bar\ndisplay overlay', + "half", "Weeks start Monday. The current week is incomplete. Recorded running only; other training or unimported runs may explain gaps."), + ("Performance ladder", "runs\n" + common + + 'summarize min(best_5km) as "Best 5 km", min(best_10km) as "Best 10 km"\nby month\ncompare none\nchart line\ndisplay overlay', + "half", "Fastest recorded elapsed windows inside each month's activities. Missing distances stay missing. These are not standardized races or terrain-matched performances."), + ("Long-run continuity", "days\n" + common + + f"long run distance >= {long_run:g} km\n" + + 'summarize latest(days_since_long_run) as "Days since long run", latest(rolling_28d_distance) as "28-day km"\nby day\ncompare none\nchart line\ndisplay overlay', + "half", f"Long run means at least {long_run:g} km; edit this threshold. Daily values advance on rest days. No earlier qualifying run is missing rather than a fabricated gap. The 28-day window includes today and the preceding 27 local days."), + ("Endurance fade", "intervals\n" + common + + "reduce by run min 20 min\nwhere elapsed >= 10 min\nand duration >= 25 s\n" + "and grade between -2 and 2 %\nand grade_variability <= 2 %\n" + "and pace_variability <= 15 %\nand sensor_coverage >= 90 %\n" + "and hr_artifact_percent <= 0 %\n" + 'summarize heart_rate_drift as "Post-warm-up decoupling"\nby run\nsort by date asc\ncompare none\nchart line\nreference heart_rate_drift = 0 %', + "half", "Matched moving-time halves after warm-up, with at least ten minutes of usable HR and terrain in each half. Hidden when halves differ more than 10% in speed or one percentage point in mean grade. Filtered windows need not be adjacent; the result remains descriptive."), + ("Daily workload and consistency", "days\n" + common + + 'summarize latest(rolling_7d_distance), latest(rolling_28d_distance), latest(run_day_streak)\nby day\ncompare none\nchart line\ndisplay overlay', + "half", "Daily workload includes rest days, uses local calendar windows and includes all recorded runs before filters. Streak returns to zero on a rest day."), + ("Longest run each week", "runs\n" + common + + 'summarize max(distance), count()\nby week\ncompare none\nchart bar', + "half", "Weekly longest recorded distance and session count; inspect the full week's volume for concentration."), + ("Short-distance performance", "runs\n" + common + + 'summarize min(best_1km), min(best_2km)\nby month\ncompare none\nchart line\ndisplay overlay', + "half", "Fastest recorded 1 km and 2 km elapsed windows; effort and terrain may differ."), + ("Recovery context", "health\n" + common + + 'summarize resting_hr, hrv, sleep_hours, stress\nby day\ncompare none\nchart line', + "full", "Private Garmin daily observations, without interpolation or a composite readiness score. Empty plots mean the watch/provider has no usable observations. Configure recovery sync in Settings."), + ("Personal HR-band minutes", "runs\n" + common + + 'summarize sum(lower_hr_minutes), sum(middle_hr_minutes), sum(upper_hr_minutes)\nby week\ncompare none\nchart bar\ndisplay overlay', + "full", "Actual recorded moving seconds in your own HR bands, excluding suspect HR intervals. Set both boundaries in Settings; no thresholds are assumed."), + ("Mechanics at matched effort", "intervals\n" + common + + "reduce by run min 3 min\n" + match + + '\nand cadence_coverage >= 80 %\nand ground_contact_coverage >= 80 %\n' + 'summarize mean(cadence), mean(ground_contact), mean(stride_length)\nby run\nsort by date asc\ncompare none\nchart line', + "full", "Recording-level mechanics matched to the same flat pace band after warm-up. Missing measurements stay missing. Differences are observations, not technique prescriptions."), + ("Recording evidence quality", "intervals\n" + common + + 'summarize mean(sensor_coverage), mean(terrain_coverage), mean(hr_artifact_percent)\nby week\ncompare none\nchart line\ndisplay overlay', + "full", "Coverage and coarse HR jump/range checks across continuous windows. These checks cannot detect every sensor artifact, including cadence lock."), + ] + if route_id: + definitions.append(("Repeated-route benchmark", "runs\n" + common + + f"route similar to {route_id}\n" + + 'summarize pace, heart_rate\nby run\nsort by date asc\ncompare none\nchart line', + "full", "Approximate full-course matching against the reference run: same start/direction/end, length within 10%, mean course separation at most 100 m and 90th-percentile separation at most 200 m. Recording breaks are excluded. Inspect contributing routes before interpreting a trend.")) + return [{"name": name, "query": source, "width": width, "notes": notes, + "pinned": i < 5, "view": "chart", "position": i, + "collection": "Training signals"} for i, (name, source, width, notes) in enumerate(definitions)] + + +def install(user, definitions): + existing = {r.name: r for r in db.session.scalars(db.select(SavedAnalysis).where( + SavedAnalysis.owner_id == user.id, SavedAnalysis.collection == "Training signals"))} + created = 0 + count = db.session.scalar(db.select(db.func.count(SavedAnalysis.id)).where( + SavedAnalysis.owner_id == user.id)) + if count + sum(d["name"] not in existing for d in definitions) > 100: + raise ValueError("Keep at most 100 account analyses; remove an unused analysis first.") + for definition in definitions: + if definition["name"] in existing: + continue # Preserve any edits made in the account library. + q = parse_query({"query": definition["query"]}) + explore(user.id, user.timezone, q) + row = SavedAnalysis(owner_id=user.id, **{**definition, "query": query_text(q)}) + db.session.add(row) + created += 1 + db.session.commit() + return created + + +def register_cli(app): + @app.cli.command("install-training-signals") + @click.option("--email", required=True) + @click.option("--pace-low", default="6:30") + @click.option("--pace-high", default="7:00") + @click.option("--long-run", default=10.0, type=click.FloatRange(1, 100)) + def install_signals(email, pace_low, pace_high, long_run): + user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower())) + if not user: + raise click.ClickException("Account not found.") + reference = db.session.scalar(db.select(Activity.id).where( + Activity.owner_id == user.id, Activity.has_route.is_(True), + Activity.sport == "running").order_by(Activity.started_at.desc()).limit(1)) + try: + count = install(user, signal_queries(pace_low, pace_high, long_run, reference)) + except ValueError as error: + db.session.rollback() + raise click.ClickException(str(error)) from None + click.echo(f"Created {count} editable analyses; the first five are pinned.") diff --git a/app/training_sources.py b/app/training_sources.py new file mode 100644 index 0000000..8a02c8f --- /dev/null +++ b/app/training_sources.py @@ -0,0 +1,155 @@ +"""Owner-scoped daily observations and matched recording reductions.""" + +from collections import defaultdict +from copy import copy +from datetime import datetime, timedelta +from types import SimpleNamespace + +from .extensions import db +from .models import DailyHealth +from .stats_analysis import evaluate, expression_tree, valid + + +def daily(history, owner_id, zone, start, end, q, catalog): + by_day = defaultdict(list) + for row in history: + by_day[row.day].append(row) + observations = {r.day: r for r in db.session.scalars( + db.select(DailyHealth).where(DailyHealth.owner_id == owner_id, + DailyHealth.day >= start, DailyHealth.day < end))} + result = [] + last_run = last_long = None + streak = 0 + earlier = [r for r in history if r.day < start] + if earlier: + last_run = max(r.day for r in earlier) + longs = [r.day for r in earlier if r.distance_m >= q["long_run"] * 1000] + last_long = max(longs) if longs else None + day = start - timedelta(days=1) + while by_day[day]: + streak += 1 + day -= timedelta(days=1) + for index in range((end - start).days): + day = start + timedelta(days=index) + rows = by_day[day] + if rows: + last_run = day + streak += 1 + else: + streak = 0 + if any(r.distance_m >= q["long_run"] * 1000 for r in rows): + last_long = day + values = {key: None for key in catalog} + distance = sum(r.distance_m for r in rows) + duration = sum(r.duration_s for r in rows) + values.update(distance=distance / 1000, duration=duration / 3600, + sessions=len(rows), count=1, run_day_streak=streak, + days_since_run=(day - last_run).days if last_run else None, + days_since_long_run=(day - last_long).days if last_long else None) + for days in (7, 28): + window = [r for d in range(days) for r in by_day[day - timedelta(days=d)]] + values[f"rolling_{days}d_distance"] = sum(r.distance_m for r in window) / 1000 + values[f"rolling_{days}d_count"] = len(window) + if days == 7: + total = sum(r.distance_m for r in window) + values["long_run_share"] = max(r.distance_m for r in window) / total * 100 if total else None + for field in ("perceived_effort", "fatigue"): + known = [r.values[field] for r in rows if valid(r.values.get(field))] + values[field] = sum(known) / len(known) if known else None + health = observations.get(day) + if health: + values.update({key: value for key, value in health.values.items() if key in catalog}) + if q["source"] == "health" and not (health and any(valid(v) for v in health.values.values())): + continue + instant = datetime.combine(day, datetime.min.time(), zone) + row = SimpleNamespace(id=day.toordinal(), day=day, local=instant, instant=instant, + started_at=instant, title=f"Training day · {day}", + distance_m=distance, duration_s=duration, elevation_m=None, + avg_hr=None, analysis_summary=values, values=values, previous=None, + activity_id=None, activity_ids=[r.id for r in rows]) + result.append(row) + for spec in q["derived"]: + if "window" in spec: + config = spec["window"] + from .stats_analysis import statistic + + for row in result: + window = [r.values.get(config["field"]) for r in result + if row.day - timedelta(days=config["days"]) < r.day <= row.day] + row.values[spec["name"]] = statistic(window, config["function"]) + else: + tree = expression_tree(spec["expression"], catalog)[0] + for row in result: + row.values[spec["name"]] = evaluate(tree, row.values) + return result + + +def reduce_runs(rows, minimum, catalog): + """Each session contributes exactly one observation after recording filters.""" + groups = defaultdict(list) + for row in rows: + groups[row.activity_id].append(row) + result = [] + for activity_id, units in groups.items(): + units.sort(key=lambda r: r.values.get("elapsed") or r.values.get("moving_offset") or 0) + duration = sum(r.duration_s for r in units) + if duration + 1e-7 < minimum * 60: + continue + row = copy(units[0]) + row.id = row.activity_id = activity_id + row.title = getattr(row, "parent_title", row.title) + row.distance_m = sum(r.distance_m for r in units) + row.duration_s = duration + values = {key: None for key in catalog} + for key in catalog: + coverage_key = "sensor_coverage" if key == "heart_rate" else key + "_coverage" + pairs = [(r.values.get(key), r.duration_s * r.values.get(coverage_key, 100) / 100) + for r in units if valid(r.values.get(key))] + weight = sum(w for _, w in pairs) + values[key] = sum(v * w for v, w in pairs) / weight if weight else None + values.update(distance=row.distance_m / 1000, duration=duration / 3600, + pace=duration * 1000 / row.distance_m, count=1, + matched_minutes=duration / 60, matched_windows=len(units)) + # Post-filter halves of matched moving time. Require >=10 minutes each, + # comparable pace (10%) and mean signed grade (one percentage point). + halves = [[0.0, 0.0, 0.0, 0.0, 0.0] for _ in range(2)] + elapsed = 0.0 + for unit in units: + hr, grade = unit.values.get("heart_rate"), unit.values.get("grade") + recorded = getattr(unit, "recording", {}) + fraction = recorded.get("joint_coverage", 100) / 100 + hr = recorded.get("joint_hr", hr) + grade = recorded.get("joint_grade", grade) + speed = recorded.get("joint_speed", unit.distance_m / unit.duration_s) + for index, low, high in ((0, 0, duration / 2), (1, duration / 2, duration)): + dt = max(0, min(elapsed + unit.duration_s, high) - max(elapsed, low)) + if dt and valid(hr) and valid(grade) and valid(speed): + dt *= fraction + part = halves[index] + part[0] += dt + part[1] += speed * dt + part[2] += hr * dt + part[3] += grade * dt + part[4] += dt + elapsed += unit.duration_s + drift = None + if all(p[0] >= 600 and p[0] >= duration * .4 and p[1] > 0 and p[2] > 0 for p in halves): + first, second = halves + speeds = [p[1] / p[0] for p in halves] + grades = [p[3] / p[0] for p in halves] + if abs(speeds[1] / speeds[0] - 1) <= .1 and abs(grades[1] - grades[0]) <= 1: + drift = (1 - (second[1] / second[2]) / (first[1] / first[2])) * 100 + values["heart_rate_drift"] = drift + row.avg_hr = values["heart_rate"] + row.values = row.analysis_summary = values + result.append(row) + return result + + +def evidence(rows, source): + ids = {getattr(row, "activity_id", row.id) for row in rows} + ids.discard(None) + if source in {"days", "health"}: + ids = {i for row in rows for i in row.activity_ids} + return {"sessions": len(ids), "inputs": len(rows), + "minutes": round(sum(row.duration_s for row in rows) / 60, 1)} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 61f3fc4..3235b2d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,24 @@ # Dokku deployment App: `stride`; database: `stride-db`; domain: `stride.peterstockings.com`. + +Training-signal releases migrate private daily recovery and session preferences +automatically. Back up PostgreSQL before release, then recompute older recordings: + +```sh +dokku run stride env FLASK_APP=app flask backfill-analysis --batch-size 20 +dokku run stride env FLASK_APP=app flask install-training-signals --email ACCOUNT +dokku run stride env FLASK_APP=app flask backfill-health --email ACCOUNT --days 90 +``` + +The installer validates queries before saving, pins five analyses and preserves +existing edits on repeat runs. Pace and long-run thresholds are editable CLI +options and account queries. Recovery backfill uses the existing encrypted +session and account lock, enables recovery only for that account and leaves +missing watch observations missing. Other accounts can opt in through Settings; +recovery failures do not stop activity imports. Never run a local copy of the +same Garmin session concurrently with production. Analysis version 3 persists +continuous windows; no dashboard query loads full recording samples. The Dockerfile installs locked dependencies on Python 3.12. Dokku runs database migrations in the predeploy step, then one web container and one sync worker. diff --git a/docs/STATS_DSL.md b/docs/STATS_DSL.md index 28ecde4..cfe3cdf 100644 --- a/docs/STATS_DSL.md +++ b/docs/STATS_DSL.md @@ -1,5 +1,117 @@ # Run analysis DSL +## Deep training signals + +`intervals` reads persisted, non-overlapping continuous moving windows of up to +30 seconds. Recording breaks over ten seconds, distance resets, stops and +implausible movement end a window; windows never join across those breaks. +Filter `duration >= 25 s` to remove short fragments. `elapsed` and +`moving_offset` are minutes since recording start and usable moving start. +Elapsed offsets use relative time even when FIT timestamps are Unix timestamps. + +```text +intervals + during last 16 weeks + reduce by run min 3 min + where elapsed >= 10 min + and duration >= 25 s + and pace between 6:30 and 7:00 /km + and grade between -2 and 2 % + and grade_variability <= 2 % + and pace_variability <= 15 % + and sensor_coverage >= 90 % + and hr_artifact_percent = 0 % + summarize mean(heart_rate) as "Matched HR" + by run + sort by date asc + compare none + chart line +``` + +Filters run **before** `reduce by run`. A session needs the specified total +matched moving minutes to contribute one observation. Within-session HR and +mechanics are weighted by usable sensor time; `mean` across reduced observations +weights each session equally. `count()` then counts independent sessions rather +than windows. Matched windows can be separated in time. This is different from +requiring one uninterrupted three-minute section. Charts show distinct-session +and usable-minute coverage, and run plots preserve calendar spacing. + +`grade` is duration-weighted signed grade using the existing smoothed elevation +model; `grade_variability` is its population SD in percentage points. GAP and +grade require at least 80% terrain coverage. Window-level cadence, power, +stride length, vertical oscillation and ground contact include sensor coverage +metrics. HR outside 30–240 bpm or a jump exceeding 20 bpm per second is excluded +and counted in `hr_artifact_percent`. These coarse checks do not detect every +sensor artifact or cadence lock. Terrain, HR lag, weather and session intent +remain confounders; the matcher does not claim to remove them. + +After reduction, `heart_rate_drift` is recomputed over matched moving-time +halves, excluding any filtered warm-up. Each half needs at least ten minutes of +joint usable HR and terrain, and 80% of the half's time. Halves must differ by +at most 10% in speed and one percentage point of mean grade. Otherwise the +result is missing. Positive means lower speed/HR efficiency later. This is a +descriptive comparison, not an overtraining diagnosis. Recording-level flags +require reduction to independent sessions; a baseline still needs ten runs. + +`efforts` expands the persisted best elapsed windows. Use `where distance = 5 km` +and `min(effort_time)` for a 5 km progression. Whole runs expose `best_1km`, +`best_2km`, `best_5km`, and `best_10km`, which accept second/minute filters and +display elapsed min:sec. No attempt at that distance remains missing. These +efforts are windows inside runs, not standardized races. + +```text +days + during last 16 weeks + long run distance >= 10 km + summarize latest(days_since_long_run), latest(rolling_28d_distance) + by day + compare none + chart line + display overlay +``` + +`days` includes rest days. `sessions`, `days_since_run`, `days_since_long_run`, +`run_day_streak`, and `long_run_share` expose continuity and concentration. +`long_run_share` is the largest individual run's share of the current seven-day +distance. Daily 7/28-day windows include the current local day plus the previous +6/27 local days, including history before the query. Whole-run rolling metrics +continue to use elapsed 168/672-hour windows. Missing prior long-run history +does not become a fabricated gap. `latest(field)` selects the latest input by +timestamp regardless of sort order, retaining missing values on that input; +it is useful for current dashboard values and cannot define a baseline band. + +`health` reads owner-only Garmin resting HR, overnight HRV, sleep duration and +stress observations (`resting_hr`, `hrv`, `sleep_hours`, `stress`). Enable the +optional recovery sync in Settings. The worker refreshes today/yesterday and +backfills a bounded batch of earlier dates. Provider gaps and unsupported watch +observations stay missing; no readiness score is inferred. Daily inputs link +to their contributing runs when present; rest/recovery-only days do not invent +activity links. Daily sources also support calendar rolling calculations. + +Private activity notes expose `perceived_effort` (1–10) and `fatigue` (0–10). +They are never shown on public or friend activity pages. Settings accepts two +personal HR boundaries. `lower_hr_minutes`, `middle_hr_minutes`, and +`upper_hr_minutes` sum actual usable moving sample time, rather than assigning +an entire run/window according to its average HR. Boundary HR belongs to the +higher band. Missing thresholds mean missing results; physiological zones are +never assumed. + +`route similar to ID` restricts recordings to an approximate full-course match +against one of your own runs. The comparison resamples compact geometry along +distance, requires endpoints within 100 m, length within 10%, mean course +separation ≤100 m and 90th-percentile separation ≤200 m. Different directions +and recording breaks are excluded; geometry shorter than 500 m is unavailable. +This is an exploratory same-start course matcher, not exact named-segment +recognition. Always inspect the contributing courses. + +Operators can run `flask --app app install-training-signals --email ACCOUNT` +to save editable examples and pin the first five. `--pace-low`, `--pace-high` +and `--long-run` customize matching. Re-running preserves existing library +edits. Recompute old recordings with `backfill-analysis` (version 3). +`backfill-health --email ACCOUNT --days 90` imports bounded recovery history +using the normal account session lock and encrypted token rotation; only this +account's recovery sync is enabled. + ## Account library and live dashboard Save an analysis with a name, investigation collection and notes. **Save as new** diff --git a/migrations/versions/7c6e32b491ad_training_signals.py b/migrations/versions/7c6e32b491ad_training_signals.py new file mode 100644 index 0000000..e99074e --- /dev/null +++ b/migrations/versions/7c6e32b491ad_training_signals.py @@ -0,0 +1,41 @@ +"""Daily recovery, individual effort bands and private session context.""" + +import sqlalchemy as sa +from alembic import op + +revision = "7c6e32b491ad" +down_revision = "f31a0c9286bd" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("garmin_connection") as batch: + batch.add_column(sa.Column("health_enabled", sa.Boolean(), nullable=False, + server_default=sa.false())) + batch.add_column(sa.Column("health_message", sa.String(200), nullable=False, + server_default="Daily recovery sync is off.")) + with op.batch_alter_table("user") as batch: + batch.add_column(sa.Column("hr_bands", sa.JSON())) + with op.batch_alter_table("activity") as batch: + batch.add_column(sa.Column("session_context", sa.JSON(), nullable=False, + server_default="{}")) + op.create_table("daily_health", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False), + sa.Column("day", sa.Date(), nullable=False), + sa.Column("values", sa.JSON(), nullable=False), + sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("owner_id", "day")) + op.create_index("ix_daily_health_owner_id", "daily_health", ["owner_id"]) + + +def downgrade(): + op.drop_table("daily_health") + with op.batch_alter_table("activity") as batch: + batch.drop_column("session_context") + with op.batch_alter_table("user") as batch: + batch.drop_column("hr_bands") + with op.batch_alter_table("garmin_connection") as batch: + batch.drop_column("health_message") + batch.drop_column("health_enabled") diff --git a/tests/test_analysis_workspace.py b/tests/test_analysis_workspace.py index 0c57ff7..e0bcd23 100644 --- a/tests/test_analysis_workspace.py +++ b/tests/test_analysis_workspace.py @@ -40,7 +40,7 @@ def test_presets_roundtrip_and_execute(app, preset): assert parsed(query_text(q)) == q with app.app_context(): result = explore(1, "Australia/Sydney", q) - assert result["source"] == q["source"] + assert result["source"] == ("matched runs" if q["reduce"] else q["source"]) client = app.test_client() login(client) assert client.get("/stats", query_string={"query": preset["query"]}).status_code == 200 diff --git a/tests/test_training_signals.py b/tests/test_training_signals.py new file mode 100644 index 0000000..1f37123 --- /dev/null +++ b/tests/test_training_signals.py @@ -0,0 +1,306 @@ +from datetime import UTC, date, datetime +from types import SimpleNamespace + +import pytest +from sqlalchemy import event +from test_app import login + +from app.display import persist_analysis +from app.extensions import db +from app.models import Activity, DailyHealth, SavedAnalysis, User +from app.recording_windows import analyze +from app.recovery import normalize, sync_health +from app.route_benchmarks import similar +from app.stats import explore, parse_query, query_text +from app.stats_dsl import QueryError +from app.training_signals import install, signal_queries +from app.training_sources import reduce_runs + +pytest_plugins = ("test_app",) + + +def parsed(source): + return parse_query({"query": source}) + + +def recording(seconds=900, hr=140, origin=1_700_000_000, speed=2.5): + return [{"time": origin + i, "distance": speed * i, "elevation": 20, + "heart_rate": hr, "cadence": 160, "ground_contact": 300, + "stride_length": 1, "speed": speed} for i in range(seconds + 1)] + + +def add_run(day, distance=5000, duration=1800, **kwargs): + row = Activity(owner_id=1, source_key=str(day), title=str(day), sport="running", + started_at=datetime.fromisoformat(day).replace(tzinfo=UTC), + distance_m=distance, duration_s=duration, avg_hr=140, **kwargs) + db.session.add(row) + return row + + +def test_windows_preserve_epoch_offsets_duration_and_sensor_time(): + units = analyze(recording(905)) + assert len(units) == 31 + assert sum(u["duration"] for u in units) == 905 + assert sum(u["distance"] for u in units) == pytest.approx(2262.5) + assert units[20]["elapsed"] == 10 + assert units[20]["moving_offset"] == 10 + assert units[-1]["duration"] == 5 + assert all(u["grade"] == 0 for u in units) + assert sum(sum(u["hr_seconds"].values()) for u in units) == 905 + + +def test_windows_do_not_bridge_stops_or_recording_breaks(): + rows = recording(20) + rows += [{**r, "time": r["time"] + 40, "distance": r["distance"] + 50} + for r in recording(20)[1:]] + units = analyze(rows) + assert [u["duration"] for u in units] == [20, 19] + assert units[1]["elapsed"] == pytest.approx(41 / 60) + rows = recording(60) + for r in rows[20:41]: + r["distance"] = 50 + units = analyze(rows) + assert all(u["duration"] <= 30 for u in units) + assert sum(u["duration"] for u in units) < 60 + + +def test_sensor_artifacts_and_missing_terrain_are_not_filled(): + rows = recording(30) + rows[10]["heart_rate"] = 250 + for row in rows: + row["elevation"] = None + unit = analyze(rows)[0] + assert unit["sensor_coverage"] < 100 + assert unit["hr_artifact_percent"] > 0 + assert "250" not in unit["hr_seconds"] + assert unit["grade"] is unit["gap"] is None + assert unit["terrain_coverage"] == 0 + + +def test_matched_sessions_are_independent_and_filters_precede_reduction(app): + with app.app_context(): + db.session.query(Activity).delete() + first = add_run("2026-09-01", samples=recording(1800, 150)) + second = add_run("2026-09-02", samples=recording(900, 130)) + db.session.flush() + for row in (first, second): + persist_analysis(row) + db.session.commit() + q = parsed("intervals\nduring 2026-09-01 to 2026-09-03\nreduce by run min 3 min\n" + "where elapsed >= 10 min\nand duration >= 25 s\nand grade between -2 and 2 %\n" + "summarize mean(heart_rate), count()\nby all\ncompare none") + result = explore(1, "UTC", q) + assert result["count"] == result["coverage"]["sessions"] == 2 + assert result["cards"][0]["value"] == "140.0" + assert result["coverage"]["minutes"] == 25 + assert {row["id"] for row in result["rows"]} == {first.id, second.id} + q["reduce"] = 6 + assert explore(1, "UTC", q)["count"] == 1 + + +def unit(activity_id, index, hr=140, speed=2.5, grade=0): + values = {"heart_rate": hr, "sensor_coverage": 100, "grade": grade, + "elapsed": index / 2, "pace": 1000 / speed} + return SimpleNamespace(id=activity_id * 1000 + index, activity_id=activity_id, + title="window", parent_title="run", duration_s=30, + distance_m=30 * speed, values=values) + + +def test_post_warmup_decoupling_requires_comparable_halves(): + rows = [unit(1, i, 130 if i < 20 else 143) for i in range(40)] + catalog = {k: None for k in ["heart_rate", "heart_rate_drift", "grade", "pace"]} + reduced = reduce_runs(rows, 20, catalog)[0] + assert reduced.values["heart_rate_drift"] == pytest.approx(100 / 11) + assert reduce_runs(rows[:39], 20, catalog) == [] + for row in rows[20:]: + row.distance_m *= 1.2 + assert reduce_runs(rows, 20, catalog)[0].values["heart_rate_drift"] is None + for row in rows: + row.values["grade"] = None + assert reduce_runs(rows, 20, catalog)[0].values["heart_rate_drift"] is None + + +def test_daily_windows_include_rest_days_and_prior_history(app): + with app.app_context(): + db.session.query(Activity).delete() + add_run("2026-09-01", 10000) + add_run("2026-09-02", 5000) + add_run("2026-09-03", 5000) + db.session.commit() + q = parsed("days\nduring 2026-09-02 to 2026-09-09\n" + "summarize latest(rolling_7d_distance), latest(run_day_streak), latest(days_since_long_run)\n" + "by day\ncompare none") + result = explore(1, "UTC", q) + assert len(result["groups"]) == 8 + assert [g["raw_values"] for g in result["groups"][:3]] == [[15, 2, 1], [20, 3, 2], [20, 0, 3]] + assert result["groups"][-1]["raw_values"] == [5, 0, 8] + assert result["cards"][0]["value"] == "5.0" + assert result["rows"][0]["id"] is None + assert result["coverage"]["sessions"] == 2 + q["long_run"] = 20 + assert explore(1, "UTC", q)["cards"][-1]["value"] == "—" + + +def test_daily_timezone_and_latest_missing_value(app): + with app.app_context(): + db.session.query(Activity).delete() + add_run("2026-09-01T23:30", 10000) + db.session.add_all([DailyHealth(owner_id=1, day=date(2026, 9, 2), values={"resting_hr": 50}), + DailyHealth(owner_id=1, day=date(2026, 9, 3), values={"sleep_hours": 8}), + DailyHealth(owner_id=2, day=date(2026, 9, 3), values={"resting_hr": 999})]) + db.session.commit() + q = parsed("health\nduring 2026-09-02 to 2026-09-03\nsummarize latest(resting_hr), latest(sleep_hours)\nby day\ncompare none") + result = explore(1, "Australia/Sydney", q) + assert result["count"] == 2 + assert [c["value"] for c in result["cards"]] == ["—", "8.0"] + assert result["groups"][0]["raw_values"][0] == 50 + assert result["coverage"]["sessions"] == 1 + + +def test_actual_hr_band_seconds_are_not_window_average_classifications(app): + with app.app_context(): + db.session.query(Activity).delete() + row = add_run("2026-09-02", samples=recording(60)) + for i, sample in enumerate(row.samples): + sample["heart_rate"] = 130 if i <= 30 else 150 + persist_analysis(row) + db.session.get(User, 1).hr_bands = [140, 160] + db.session.commit() + q = parsed("runs\nduring all time\nsummarize sum(lower_hr_minutes), sum(middle_hr_minutes), sum(upper_hr_minutes)\nby all\ncompare none") + result = explore(1, "UTC", q) + assert result["groups"][0]["raw_values"] == [0.5, 0.5, 0] + db.session.get(User, 1).hr_bands = None + db.session.commit() + assert explore(1, "UTC", q)["groups"][0]["raw_values"] == [None, None, None] + + +def test_efforts_and_performance_metrics_preserve_missing_distances(app): + with app.app_context(): + row = db.session.get(Activity, 1) + row.best_efforts = [{"distance": 1000, "seconds": 301}, {"distance": 5000, "seconds": 1800}] + db.session.commit() + q = parsed("efforts\nduring all time\nwhere distance = 5 km\nsummarize min(effort_time)\nby all\ncompare none") + result = explore(1, "UTC", q) + assert result["cards"][0]["value"] == "30:00" + assert result["rows"][0]["id"] == 1 + q = parsed("runs\nduring all time\nsummarize min(best_5km), min(best_10km)\nby month\ncompare none") + assert [c["value"] for c in explore(1, "UTC", q)["cards"]] == ["30:00", "—"] + + +@pytest.mark.parametrize("definition", signal_queries(route_id=1), ids=lambda d: d["name"]) +def test_proposed_queries_roundtrip_execute_and_render(app, definition): + q = parsed(definition["query"]) + assert parsed(query_text(q)) == q + with app.app_context(): + explore(1, "Australia/Sydney", q) + client = app.test_client() + login(client) + assert client.get("/stats", query_string={"query": definition["query"]}).status_code == 200 + + +@pytest.mark.parametrize("source", ["runs", "days", "health", "efforts"]) +def test_reduction_rejects_non_recording_sources(source): + with pytest.raises(QueryError): + parsed(source + "\nreduce by run min 3 min\nsummarize heart_rate") + + +def test_queries_never_load_recordings_or_other_accounts(app): + with app.app_context(): + statements = [] + def capture(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + event.listen(db.engine, "before_cursor_execute", capture) + try: + for source in ("runs", "intervals", "efforts", "days", "health"): + explore(1, "UTC", parsed(source + "\nduring last 16 weeks\nsummarize heart_rate\nby day")) + finally: + event.remove(db.engine, "before_cursor_execute", capture) + assert not any("activity.samples" in sql or "activity.display_data" in sql for sql in statements) + + +def test_route_matching_keeps_direction_and_rejects_breaks_and_other_owners(app): + line = [[[-33, 151], [-33, 151.005], [-33, 151.01]]] + assert similar(line, line) + assert not similar(line, [list(reversed(line[0]))]) + assert not similar(line, [line[0][:2], line[0][2:]]) + with app.app_context(): + db.session.get(Activity, 1).route_geometry = line + other = Activity(owner_id=2, source_key="other-route", title="other", sport="running", + started_at=datetime(2026, 9, 30, tzinfo=UTC), route_geometry=line) + db.session.add(other) + db.session.commit() + q = parsed(f"runs\nduring all time\nroute similar to {other.id}\nsummarize pace") + with pytest.raises(QueryError, match="own recorded"): + explore(1, "UTC", q) + + +def test_installation_is_idempotent_private_and_keeps_edits(app): + with app.app_context(): + user = db.session.get(User, 1) + definitions = signal_queries() + assert install(user, definitions) == 12 + rows = db.session.scalars(db.select(SavedAnalysis).filter_by(owner_id=1)).all() + assert sum(r.pinned for r in rows) == 5 + rows[0].notes = "My annotation" + db.session.commit() + assert install(user, definitions) == 0 + assert rows[0].notes == "My annotation" + client = app.test_client() + login(client, "other") + assert client.get("/stats/analyses").json["analyses"] == [] + + +def test_private_context_and_bands_validate_and_do_not_leak(app): + client = app.test_client() + login(client) + assert client.post("/training/bands", data={"lower": 145, "upper": 140}).status_code == 400 + assert client.post("/training/bands", data={"lower": 140, "upper": 160}).status_code == 302 + assert client.post("/activities/1/context", data={"perceived_effort": 11}).status_code == 400 + assert client.post("/activities/1/context", data={"perceived_effort": 7, "fatigue": 3, "notes": "PRIVATE-CONTEXT"}).status_code == 302 + assert b"PRIVATE-CONTEXT" in client.get("/activities/1").data + with app.app_context(): + db.session.get(Activity, 1).share_token = "test-context-token" + db.session.commit() + assert b"PRIVATE-CONTEXT" not in app.test_client().get("/shared/runs/test-context-token").data + other = app.test_client() + login(other, "other") + assert other.post("/activities/1/context", data={"notes": "attack"}).status_code == 404 + app.config["WTF_CSRF_ENABLED"] = True + assert client.post("/activities/1/context", data={}).status_code == 400 + assert client.post("/training/bands", data={}).status_code == 400 + + +def test_recovery_mapping_does_not_turn_provider_missing_sentinels_into_data(): + assert normalize({"restingHeartRate": None, "averageStressLevel": -1}, {}, {}) == {} + assert normalize({"restingHeartRate": 55, "averageStressLevel": 32}, + {"hrvSummary": {"lastNightAvg": 48}}, + {"dailySleepDTO": {"sleepTimeSeconds": 28800}}) == { + "resting_hr": 55, "stress": 32, "hrv": 48, "sleep_hours": 8} + + +def test_daily_import_is_bounded_owner_scoped_and_stops_on_failure(app): + class API: + calls = 0 + fail = False + def get_stats(self, _day): + self.calls += 1 + if self.fail: + raise RuntimeError("PROVIDER-SECRET") + return {"restingHeartRate": 50} + def get_hrv_data(self, _day): + self.calls += 1 + return {"hrvSummary": {"lastNightAvg": 45}} + def get_sleep_data(self, _day): + self.calls += 1 + return {"dailySleepDTO": {"sleepTimeSeconds": 28800}} + with app.app_context(): + api = API() + user = db.session.get(User, 1) + assert sync_health(api, user, days=30, limit=4)["days"] == 4 + assert api.calls == 12 + assert db.session.scalar(db.select(db.func.count(DailyHealth.id)).where(DailyHealth.owner_id == 1)) == 4 + assert db.session.scalar(db.select(db.func.count(DailyHealth.id)).where(DailyHealth.owner_id == 2)) == 0 + api.fail = True + result = sync_health(api, user, days=30, limit=10) + assert result["days"] == 1 and result["failures"] == 1 + assert "PROVIDER-SECRET" not in result["message"] diff --git a/tests/test_web_sync.py b/tests/test_web_sync.py index 1483e69..dd176e6 100644 --- a/tests/test_web_sync.py +++ b/tests/test_web_sync.py @@ -10,7 +10,36 @@ from garminconnect import GarminConnectAuthenticationError from app import create_app from app.extensions import db from app.models import GarminConnection, User -from app.sync import run_due, session_lock +from app.sync import cipher, run_due, session_lock + + +def test_optional_recovery_failure_does_not_break_activity_sync(connected, monkeypatch): + app, _client, _api, _sync = connected + def failed_health(*_args): + raise RuntimeError("PRIVATE-PROVIDER-RESPONSE") + monkeypatch.setattr("app.recovery.sync_health", failed_health) + with app.app_context(): + connection = db.session.scalar(db.select(GarminConnection)) + connection.health_enabled = True + db.session.commit() + assert run_due() == 1 + assert connection.failures == 0 and connection.last_success + assert "Activity sync completed" in connection.health_message + assert "PRIVATE-PROVIDER" not in connection.health_message + + +def test_recovery_login_failure_preserves_encrypted_session(connected): + app, _client, api, _sync = connected + api.login.side_effect = RuntimeError("PRIVATE-LOGIN-RESPONSE") + with app.app_context(): + original = db.session.scalar(db.select(GarminConnection)).tokens + result = app.test_cli_runner().invoke(args=["backfill-health", "--email", "owner@example.test", "--days", "2"]) + assert result.exit_code == 1 and "PRIVATE-LOGIN" not in result.output + with app.app_context(): + connection = db.session.scalar(db.select(GarminConnection)) + assert connection.tokens == original + assert not connection.health_enabled + assert cipher().decrypt(connection.tokens.encode()) @pytest.fixture