{{ lap.heart_rate|int if lap.heart_rate else '—' }}
{% else %}
No laps recorded.
{% endfor %}
{% if owner_view %}YOUR RUN, YOUR CHOICE
Sharing settings
{% 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.
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 %}
{{ 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"
{{ 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 %}
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