diff --git a/app/__init__.py b/app/__init__.py index 04aa8b7..21533d9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -70,6 +70,9 @@ def create_app(config=None): from .records import register_cli as register_records_cli register_records_cli(app) + from .display import register_cli as register_display_cli + + register_display_cli(app) @login_manager.user_loader def load_user(user_id): diff --git a/app/display.py b/app/display.py new file mode 100644 index 0000000..a8c1073 --- /dev/null +++ b/app/display.py @@ -0,0 +1,174 @@ +"""Versioned, compact display data calculated from full-resolution recordings.""" + +import math +from types import SimpleNamespace + +import click + +from .analytics import analyse, numeric + +ANALYSIS_VERSION = 1 +CHART_LIMIT = 750 +ROUTE_TARGET = 1400 +CHART_KEYS = ( + "distance", + "heart_rate", + "speed", + "elevation", + "grade", + "gap", + "pace", + "cadence", + "power", + "stride_length", + "vertical_oscillation", + "ground_contact", +) +ROUTE_KEYS = ("distance", "speed", "heart_rate", "grade") + + +def evenly_spaced(length, limit): + if length <= limit: + return set(range(length)) + return {round(i * (length - 1) / (limit - 1)) for i in range(limit)} + + +def simplify_indices(rows, tolerance): + """Iterative Douglas-Peucker in local metre coordinates; retain endpoints.""" + if len(rows) <= 2: + return set(range(len(rows))) + latitude = math.radians(rows[0]["point"][0]) + points = [ + (row["point"][1] * 111320 * math.cos(latitude), row["point"][0] * 111320) for row in rows + ] + kept = {0, len(rows) - 1} + stack = [(0, len(rows) - 1)] + while stack: + start, end = stack.pop() + ax, ay = points[start] + bx, by = points[end] + dx, dy = bx - ax, by - ay + denominator = dx * dx + dy * dy + furthest, maximum = None, tolerance * tolerance + for index in range(start + 1, end): + px, py = points[index] + fraction = ( + max(0, min(1, ((px - ax) * dx + (py - ay) * dy) / denominator)) + if denominator + else 0 + ) + squared = (px - ax - fraction * dx) ** 2 + (py - ay - fraction * dy) ** 2 + if squared > maximum: + furthest, maximum = index, squared + if furthest is not None: + kept.add(furthest) + stack.extend(((start, furthest), (furthest, end))) + return kept + + +def compact_routes(samples): + segments, segment = [], [] + for sample in samples: + point = sample.get("point") + if isinstance(point, list) and len(point) == 2 and all(numeric(v) for v in point): + segment.append(sample) + elif segment: + segments.append(segment) + segment = [] + if segment: + segments.append(segment) + total = sum(len(segment) for segment in segments) + result = [] + for segment in segments: + # Preserve turns plus regular measurement samples for route colours. + budget = max(2, round(ROUTE_TARGET * len(segment) / total)) + regular = evenly_spaced(len(segment), max(2, budget // 2)) + tolerance = 3.0 + kept = simplify_indices(segment, tolerance) | regular + while len(kept) > budget: + tolerance *= 2 + kept = simplify_indices(segment, tolerance) | regular + result.append( + [ + { + "point": [round(v, 6) for v in segment[i]["point"]], + **{ + key: round(segment[i][key], 3) if numeric(segment[i].get(key)) else None + for key in ROUTE_KEYS + }, + } + for i in sorted(kept) + ] + ) + return result + + +def persist_analysis(activity): + analysis = analyse(activity) + activity.analysis_summary = {key: analysis[key] for key in ("summary", "terrain", "laps")} + 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) + activity.display_data = { + "route_segments": routes, + "chart": [ + {key: row.get(key) for key in CHART_KEYS} + for i, row in enumerate(analysis["samples"]) + if i in chart_indices + ], + } + activity.analysis_version = ANALYSIS_VERSION + + +def stored_analysis(activity): + if activity.analysis_version == ANALYSIS_VERSION: + return activity.analysis_summary + # No on-demand full-recording fallback: pending backfills show unavailable metrics. + empty = analyse( + SimpleNamespace(samples=[], laps=[], sport=activity.sport, distance_m=activity.distance_m) + ) + return {key: empty[key] for key in ("summary", "terrain", "laps")} + + +def register_cli(app): + @app.cli.command("backfill-analysis") + @click.option("--batch-size", default=25, type=click.IntRange(1, 1000), show_default=True) + @click.option("--owner-id", type=click.IntRange(1)) + @click.option("--force", is_flag=True) + def backfill(batch_size, owner_id, force): + """Backfill summaries, charts and simplified routes in resumable batches.""" + from sqlalchemy.orm import undefer + + from .extensions import db + from .models import Activity + + maximum = db.session.scalar(db.select(db.func.max(Activity.id))) or 0 + last_id, count = 0, 0 + while last_id < maximum: + query = db.select(Activity).where(Activity.id > last_id, Activity.id <= maximum) + if owner_id: + query = query.where(Activity.owner_id == owner_id) + if not force: + query = query.where(Activity.analysis_version != ANALYSIS_VERSION) + rows = db.session.scalars( + query.options(undefer(Activity.samples), undefer(Activity.laps)) + .order_by(Activity.id) + .limit(batch_size) + .with_for_update() + ).all() + if not rows: + break + try: + for row in rows: + persist_analysis(row) + last_id = rows[-1].id + db.session.commit() + except Exception as error: + db.session.rollback() + raise click.ClickException( + "Backfill failed; completed batches are saved. Rerun to resume." + ) from error + count += len(rows) + db.session.expunge_all() + click.echo(f"Backfilled {count} activities (through ID {last_id}).") + click.echo(f"Complete: {count} activities backfilled; analysis version {ANALYSIS_VERSION}.") diff --git a/app/models.py b/app/models.py index efd640f..4db598d 100644 --- a/app/models.py +++ b/app/models.py @@ -57,6 +57,16 @@ class Activity(db.Model): best_efforts = db.Column(db.JSON, nullable=False, default=list, server_default="[]") efforts_version = db.Column(db.Integer, nullable=False, default=0, server_default="0") has_route = db.Column(db.Boolean, nullable=False, default=False, server_default=db.false()) + analysis_version = db.Column(db.Integer, nullable=False, default=0, server_default="0") + analysis_summary = db.deferred( + db.Column(db.JSON, nullable=False, default=dict, server_default="{}") + ) + display_data = db.deferred( + db.Column(db.JSON, nullable=False, default=dict, server_default="{}") + ) + route_geometry = db.deferred( + db.Column(db.JSON, nullable=False, default=list, server_default="[]") + ) samples = db.deferred(db.Column(db.JSON, nullable=False, default=list)) laps = db.deferred(db.Column(db.JSON, nullable=False, default=list)) created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/app/routes.py b/app/routes.py index a2dad10..12f826e 100644 --- a/app/routes.py +++ b/app/routes.py @@ -24,11 +24,12 @@ from werkzeug.security import check_password_hash, generate_password_hash from garmin_sync.service import SyncError, atomic_write -from .analytics import analyse, pace_text +from .analytics import pace_text +from .display import stored_analysis from .extensions import db, limiter from .models import Activity, Friendship, GarminConnection, User from .records import record_book -from .services import activity_data, import_cache, import_upload +from .services import activity_data, import_cache, import_upload, route_data web = Blueprint("web", __name__) DUMMY_HASH = generate_password_hash("not-a-real-account-password") @@ -76,11 +77,7 @@ def are_friends(first, second): def latest_mapped_run(rows): return next( - ( - row - for row in rows - if row.sport == "running" and row.has_route - ), + (row for row in rows if row.sport == "running" and row.has_route), None, ) @@ -311,7 +308,7 @@ def profile(username): @web.get("/activities/") def activity(activity_id): row, owner = get_activity(activity_id) - analysis = analyse(row) + analysis = stored_analysis(row) efforts = [] if owner: history = db.session.scalars(db.select(Activity).filter_by(owner_id=row.owner_id)).all() @@ -332,6 +329,12 @@ def data(activity_id): return jsonify(activity_data(row, owner)) +@web.get("/activities//route") +def route(activity_id): + row, _ = get_activity(activity_id) + return jsonify(route_data(row)) + + @web.post("/activities//share") @login_required def share_activity(activity_id): @@ -366,7 +369,7 @@ def shared_activity(token): activity=row, owner_view=False, public_view=True, - analysis=analyse(row), + analysis=stored_analysis(row), pace_text=pace_text, data_url=url_for("web.shared_activity_data", token=token), ) diff --git a/app/services.py b/app/services.py index caf01e9..24fbe08 100644 --- a/app/services.py +++ b/app/services.py @@ -6,6 +6,7 @@ from pathlib import Path from garmin_sync.service import SyncError, complete, decode_fit +from .display import ANALYSIS_VERSION, persist_analysis from .extensions import db from .models import Activity from .records import persist_efforts @@ -112,6 +113,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=Fa if existing: existing.samples, existing.laps, existing.metrics = samples, laps, metrics persist_efforts(existing) + persist_analysis(existing) return existing row = Activity( owner_id=owner_id, @@ -128,6 +130,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=Fa laps=laps, ) persist_efforts(row) + persist_analysis(row) db.session.add(row) return row @@ -159,44 +162,20 @@ def import_upload(user, payload): def activity_data(activity, owner_view=False): - """Call only after access checks. Owners and accepted friends see the full route.""" - from .analytics import analyse + """Read compact persisted data only, after the caller's access checks.""" + if activity.analysis_version != ANALYSIS_VERSION: + return {"segments": [], "route_segments": [], "chart": []} + data = activity.display_data + routes = data.get("route_segments", []) + return { + "segments": [[sample["point"] for sample in segment] for segment in routes], + "route_segments": routes, + "chart": data.get("chart", []), + } - analysis = analyse(activity) - segments, segment = [], [] - route_segments, route = [], [] - for sample in analysis["samples"]: - point = sample.get("point") - if point: - segment.append(point) - route.append(sample) - elif segment: - segments.append(segment) - segment = [] - route_segments.append(route) - route = [] - if segment: - segments.append(segment) - route_segments.append(route) - step = max(1, math.ceil(len(activity.samples) / 750)) - chart = [ - { - k: sample.get(k) - for k in ( - "distance", - "heart_rate", - "speed", - "elevation", - "grade", - "gap", - "pace", - "cadence", - "power", - "stride_length", - "vertical_oscillation", - "ground_contact", - ) - } - for sample in analysis["samples"][::step] - ] - return {"segments": segments, "route_segments": route_segments, "chart": chart} + +def route_data(activity): + """Dashboard preview: no measurements, charts or full-resolution samples.""" + return { + "segments": activity.route_geometry if activity.analysis_version == ANALYSIS_VERSION else [] + } diff --git a/app/static/dashboard.js b/app/static/dashboard.js index da4b624..b5616d3 100644 --- a/app/static/dashboard.js +++ b/app/static/dashboard.js @@ -19,7 +19,7 @@ async function showDashboardRoute() { const data = await response.json(); const segments = data.segments.filter(segment => segment.length); if (!segments.length) { status.textContent = "No GPS route recorded for this run."; return; } - const map = L.map(container, {scrollWheelZoom: false}); + const map = L.map(container, {scrollWheelZoom: false, preferCanvas: true}); L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap', }).addTo(map); @@ -48,7 +48,12 @@ async function showDashboardRoute() { if (previous !== null) elapsed += now - previous; previous = now; const distance = ((elapsed % 20000) / 20000) * length; - const edge = edges.find(edge => edge.end >= distance) || edges.at(-1); + let low = 0, high = edges.length - 1; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (edges[mid].end < distance) low = mid + 1; else high = mid; + } + const edge = edges[low]; const fraction = (distance - edge.start) / (edge.end - edge.start); runner.setLatLng([edge.a.lat + (edge.b.lat-edge.a.lat)*fraction, edge.a.lng + (edge.b.lng-edge.a.lng)*fraction]); frame = requestAnimationFrame(tick); diff --git a/app/templates/activity.html b/app/templates/activity.html index 29c8a52..72efce2 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -45,8 +45,8 @@
HILLS CHANGE THE EFFORT

Heart rate by terrain

Measured HR · not adjusted

See how your recorded heart rate and pace vary uphill, downhill and on the flat. Heart rate responds with a delay, so this is context rather than a correction.

{% for t in analysis.terrain %}
{{ t.label }}{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}bpm
{{ '%.2f'|format(t.distance/1000) }} km · {{ pace_text(t.pace) }}/km
{% endfor %}
-

Laps & splits

{{ activity.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.
+

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. Chart traces are sampled for display; computations use all available records.

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

{% endblock %} {% block scripts %}{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index ecdc251..e9457f1 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -8,7 +8,7 @@
LATEST RUN / ROUTE REPLAY

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

{% if mapped_run %}

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

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

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

{% endif %}

{{ 'Loading recorded route…' if mapped_run else 'No GPS running route available yet.' }}

-
+
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..bc5be2a --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,47 @@ +# Persisted run analysis and compact routes + +Imports and recording refreshes now calculate summaries, GAP, terrain breakdowns, +lap GAP and charts from the full-resolution samples, in the same transaction as +the activity. `analysis_version` identifies the calculation/display version. +Current-version empty results distinguish incomplete recordings from pending +backfills. Raw recordings remain intact. + +The page reads `analysis_summary`; the detail JSON endpoint reads `display_data`. +Both are deferred JSON columns so activity lists and record comparisons do not +load them. No page request calculates full-resolution analysis or reads raw laps. +Pending activities show unavailable derived metrics until backfilled. + +Dashboard maps use the authorized `/activities//route` endpoint, which reads +only the persisted `route_geometry` and returns coordinates. The full detail data +remains protected by the same owner/friend/public-link access checks as before. +No shared caching is introduced for private responses. + +Route display combines Douglas-Peucker simplification (initial tolerance 3 m) +with regular measurement samples for colours. Tolerance increases for complex +routes to fit a target of 1,400 points across the recording. Each GPS segment's +endpoints are retained, so heavily fragmented routes may exceed that target; +gaps are never joined to meet a budget. Coordinates are rounded to six decimals. +Map measurements use only distance, speed, HR and grade; other raw fields are +omitted. Chart data retains up to 750 evenly spaced samples, including endpoints. +Calculations and personal records always use full-resolution source data. + +The dashboard replay uses canvas rendering and binary search through route edges +instead of a linear search on each animation frame. + +After the schema migration, backfill existing activities: + +```sh +flask --app app backfill-analysis --batch-size 25 +# Dokku avoids interpreting Flask's --app option as a Dokku option: +dokku run stride env FLASK_APP=app flask backfill-analysis --batch-size 25 +``` + +Each batch commits independently and uses PostgreSQL row locks. Reruns skip +current-version activities. `--owner-id ID` scopes the job; `--force` rebuilds +current results. The job fixes an upper activity ID at start; concurrent imports +calculate their own results. No downloads or Garmin authentication are needed. + +Baseline measured on production on 2026-10-02, before this change: longest run +6,845 samples; median of three internal authenticated requests: dashboard HTML +8 ms, run HTML 156 ms, run JSON 213 ms / 2,654 KiB uncompressed. These timings +exclude network transfer, browser rendering and map tile downloads. diff --git a/migrations/versions/f08db2902a74_persist_run_analysis.py b/migrations/versions/f08db2902a74_persist_run_analysis.py new file mode 100644 index 0000000..a64578c --- /dev/null +++ b/migrations/versions/f08db2902a74_persist_run_analysis.py @@ -0,0 +1,31 @@ +"""Persist compact activity analysis and route previews.""" + +import sqlalchemy as sa +from alembic import op + +revision = "f08db2902a74" +down_revision = "e57a0f824c61" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("activity") as batch_op: + batch_op.add_column( + sa.Column("analysis_version", sa.Integer(), nullable=False, server_default="0") + ) + batch_op.add_column( + sa.Column("analysis_summary", sa.JSON(), nullable=False, server_default="{}") + ) + batch_op.add_column( + sa.Column("display_data", sa.JSON(), nullable=False, server_default="{}") + ) + batch_op.add_column( + sa.Column("route_geometry", sa.JSON(), nullable=False, server_default="[]") + ) + + +def downgrade(): + with op.batch_alter_table("activity") as batch_op: + for name in ("route_geometry", "display_data", "analysis_summary", "analysis_version"): + batch_op.drop_column(name) diff --git a/scripts/preview-fixture.py b/scripts/preview-fixture.py index b29eacb..891a6fd 100644 --- a/scripts/preview-fixture.py +++ b/scripts/preview-fixture.py @@ -10,8 +10,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from waitress import serve from app import create_app +from app.display import persist_analysis from app.extensions import db from app.models import Activity, User +from app.records import persist_efforts app = create_app( { @@ -36,6 +38,7 @@ with app.app_context(): { "point": [51.507 + 0.007 * math.sin(n / 70), -0.14 + n / 25000], "distance": n * 20, + "time": n * 5, "speed": 2.8 + 0.3 * math.sin(n / 20), "heart_rate": 140 + 12 * math.sin(n / 30), "elevation": 10 + n / 30, @@ -64,6 +67,10 @@ with app.app_context(): laps=[{"distance": 1000, "duration": 350, "pace": 350, "heart_rate": 145}] * 6, ) ) + for row in list(db.session.new): + if isinstance(row, Activity): + persist_efforts(row) + persist_analysis(row) db.session.commit() # Test-only auto-login, isolated to this ephemeral process/database. diff --git a/tests/test_app.py b/tests/test_app.py index 94b93d2..7ae0978 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from app import create_app +from app.display import persist_analysis from app.extensions import db from app.models import Activity, Friendship, User from app.records import EFFORTS_VERSION, persist_efforts @@ -55,6 +56,8 @@ def app(tmp_path): ) ) db.session.commit() + persist_analysis(db.session.get(Activity, 1)) + db.session.commit() yield app @@ -88,11 +91,106 @@ def test_owner_dashboard_activity_json_and_settings_render(app): assert "point" not in data["chart"][0] +def test_route_preview_access_is_revoked_with_friendship(app): + owner, friend, anonymous = app.test_client(), app.test_client(), app.test_client() + login(owner) + login(friend, "other") + assert anonymous.get("/activities/1/route").status_code == 404 + assert friend.get("/activities/1/route").status_code == 404 + assert owner.get("/activities/1/route").json == {"segments": [[[-33, 151]]]} + owner.post("/activities/1/edit", data={"title": "Shared", "visibility": "friends"}) + owner.post("/friends", data={"email": "other@example.test"}) + friend.post("/friends/1/accept") + assert friend.get("/activities/1/route").status_code == 200 + friend.post("/friends/1/remove") + assert friend.get("/activities/1/route").status_code == 404 + + +def test_stored_analysis_requests_do_not_read_raw_samples_or_recalculate(app, monkeypatch): + from sqlalchemy import event + + from app import display + + def fail(_row): + raise AssertionError("Analysis must be precomputed") + + monkeypatch.setattr(display, "analyse", fail) + with app.app_context(): + db.session.get(Activity, 1).share_token = "compact-test" + db.session.commit() + statements = [] + + def capture(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + engine = db.engine + event.listen(engine, "before_cursor_execute", capture) + client = app.test_client() + login(client) + try: + for path in ( + "/dashboard", + "/activities/1", + "/activities/1/data", + "/activities/1/route", + "/shared/runs/compact-test", + "/shared/runs/compact-test/data", + ): + assert client.get(path).status_code == 200 + assert all("activity.samples" not in s and "activity.laps" not in s for s in statements) + finally: + event.remove(engine, "before_cursor_execute", capture) + + +def test_analysis_backfill_resumes_after_failed_batch(app, monkeypatch): + from app import display + + with app.app_context(): + db.session.get(Activity, 1).analysis_version = 0 + db.session.add( + Activity( + owner_id=2, + source_key="analysis:2", + title="Older activity", + started_at=datetime(2026, 1, 1, tzinfo=UTC), + sport="cycling", + samples=[], + laps=[], + ) + ) + db.session.commit() + original = display.persist_analysis + + def interrupt(row): + if row.id == 2: + raise ValueError("Interrupted") + original(row) + + monkeypatch.setattr(display, "persist_analysis", interrupt) + runner = app.test_cli_runner() + result = runner.invoke(args=["backfill-analysis", "--batch-size", "1"]) + assert result.exit_code != 0 + with app.app_context(): + assert db.session.get(Activity, 1).analysis_version == display.ANALYSIS_VERSION + assert db.session.get(Activity, 2).analysis_version == 0 + monkeypatch.setattr(display, "persist_analysis", original) + result = runner.invoke(args=["backfill-analysis", "--owner-id", "1"]) + assert result.exit_code == 0 + assert "0 activities backfilled" in result.output + result = runner.invoke(args=["backfill-analysis", "--batch-size", "1"]) + assert result.exit_code == 0 + assert "1 activities backfilled" in result.output + result = runner.invoke(args=["backfill-analysis"]) + assert result.exit_code == 0 + assert "0 activities backfilled" in result.output + + def test_personal_records_render_and_remain_owner_only(app): with app.app_context(): row = db.session.get(Activity, 1) row.samples = [{"distance": 0, "time": 0}, {"distance": 5000, "time": 1500}] persist_efforts(row) + persist_analysis(row) row.share_token = "record-test" db.session.commit() client = app.test_client() @@ -127,6 +225,8 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app): row = db.session.get(Activity, row_id) assert row.efforts_version == EFFORTS_VERSION assert row.best_efforts[0]["seconds"] == 300 + assert row.analysis_version == 1 + assert row.analysis_summary["summary"]["samples"] == 2 assert activity_from_fit(1, "test:new", [doc]) is None doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z" activity_from_fit(1, "test:new", [doc], refresh=True) @@ -136,6 +236,8 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app): activity_from_fit(1, "test:new", [doc], refresh=True) db.session.commit() assert row.best_efforts == [] + assert row.display_data == {"route_segments": [], "chart": []} + assert row.route_geometry == [] assert row.efforts_version == EFFORTS_VERSION @@ -270,6 +372,10 @@ def test_efforts_migration_upgrades_existing_schema(tmp_path): row = db.session.get(Activity, 1) assert row.best_efforts == [] assert row.efforts_version == 0 + assert row.analysis_version == 0 + assert row.analysis_summary == {} + assert row.display_data == {} + assert row.route_geometry == [] assert row.has_route is False assert row.title == "Old run" result = runner.invoke(args=["backfill-efforts"]) @@ -282,7 +388,7 @@ def test_dashboard_replays_latest_accessible_gps_run(app): client = app.test_client() login(client) page = client.get("/dashboard").data - assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page + assert b'id="dashboard-route-map" data-url="/activities/1/route"' in page assert b'id="toggle-route-replay"' not in page assert b"vendor/leaflet.js" in page assert b'class="dashboard-map"' not in page @@ -295,6 +401,7 @@ def test_dashboard_replays_latest_accessible_gps_run(app): row = db.session.get(Activity, 1) row.samples = [] persist_efforts(row) + persist_analysis(row) db.session.commit() client.post("/logout") login(client) @@ -343,7 +450,10 @@ def test_private_activity_is_hidden_even_from_accepted_friend(app): def test_full_route_includes_start_and_finish(): points = [[0, 0], [0, 0.001], [0, 0.01], [0, 0.02]] - row = Activity(samples=[{"point": point} for point in points]) + row = Activity( + sport="running", distance_m=0, laps=[], samples=[{"point": point} for point in points] + ) + persist_analysis(row) assert activity_data(row)["segments"] == [points] diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..8d3b585 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,93 @@ +import copy +import math +from types import SimpleNamespace + +from app.analytics import analyse +from app.display import ( + ANALYSIS_VERSION, + CHART_LIMIT, + compact_routes, + persist_analysis, + stored_analysis, +) +from app.services import activity_data, route_data + + +def long_run(): + return SimpleNamespace( + sport="running", + distance_m=21000, + samples=[ + { + "point": [-33 + i / 1000000, 151 + math.sin(i / 100) / 1000], + "distance": i * 3, + "time": i, + "speed": 3, + "elevation": 100 + math.sin(i / 100), + "heart_rate": 150, + } + for i in range(7001) + ], + laps=[{"distance": 21000, "duration": 7000}], + ) + + +def test_persisted_summaries_equal_full_resolution_and_raw_data_is_untouched(): + row = long_run() + original = copy.deepcopy(row.samples) + expected = analyse(row) + persist_analysis(row) + assert row.analysis_version == ANALYSIS_VERSION + assert row.analysis_summary == {key: expected[key] for key in ("summary", "terrain", "laps")} + assert row.samples == original + assert len(row.display_data["chart"]) == CHART_LIMIT + assert row.display_data["chart"][0]["distance"] == 0 + assert row.display_data["chart"][-1]["distance"] == 21000 + assert sum(map(len, row.route_geometry)) <= 1400 + assert row.route_geometry[0][0] == [round(v, 6) for v in original[0]["point"]] + assert row.route_geometry[-1][-1] == [round(v, 6) for v in original[-1]["point"]] + assert set(route_data(row)) == {"segments"} + assert set(row.display_data["route_segments"][0][0]) == { + "point", + "distance", + "speed", + "grade", + "heart_rate", + } + assert "point" not in row.display_data["chart"][0] + + +def test_simplification_retains_sharp_turns_and_does_not_bridge_gps_gaps(): + points = [[0, i / 10000] for i in range(100)] + points += [[i / 10000, 0.0099] for i in range(1, 100)] + samples = [{"point": point} for point in points] + samples += [{"point": None}, {"point": [1, 1]}, {"point": [1, 1.01]}] + routes = compact_routes(samples) + assert len(routes) == 2 + assert [0, 0.0099] in [row["point"] for row in routes[0]] + assert routes[0][0]["point"] == points[0] + assert routes[0][-1]["point"] == points[-1] + assert routes[1][0]["point"] == [1, 1] + assert routes[1][-1]["point"] == [1, 1.01] + + +def test_incomplete_and_non_gps_data_and_refresh_clear_stored_routes(): + row = long_run() + persist_analysis(row) + row.samples = [ + {"point": None, "distance": 0, "time": 0}, + {"point": None, "distance": 100, "time": 30}, + ] + persist_analysis(row) + assert row.route_geometry == [] + assert activity_data(row)["route_segments"] == [] + assert len(activity_data(row)["chart"]) == 2 + assert stored_analysis(row)["summary"]["samples"] == 2 + row.analysis_version = 0 + assert route_data(row) == {"segments": []} + assert activity_data(row) == {"segments": [], "route_segments": [], "chart": []} + + +def test_fragmented_routes_preserve_each_singleton(): + samples = [{"point": [i / 1000, 0]} if i % 2 else {"point": None} for i in range(20)] + assert len(compact_routes(samples)) == 10