From e33946e2703ea3d741d58aa673b2d9ea39165d54 Mon Sep 17 00:00:00 2001 From: owain Date: Sat, 20 Jun 2026 20:04:53 +0100 Subject: [PATCH] feat: avg pace over moving time (fixes elapsed-based pace on paused activities); show mid-activity pauses on Body Battery band via active_spans --- backend/app/api/activities.py | 1 + backend/app/main.py | 3 ++ backend/app/models/user.py | 5 +++ backend/app/services/fit_parser.py | 44 +++++++++++++++++++++- backend/app/workers/tasks.py | 12 ++++-- frontend/src/pages/HealthPage.jsx | 60 +++++++++++++++++++++++++----- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 97b97fd..d2c0ac1 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -31,6 +31,7 @@ class ActivitySummary(BaseModel): hr_zones: Optional[dict] named_route_id: Optional[int] named_route_name: Optional[str] = None + active_spans: Optional[list] = None class Config: from_attributes = True diff --git a/backend/app/main.py b/backend/app/main.py index 7628ab3..b001dff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -59,6 +59,9 @@ async def init_db(): await conn.execute(text( "ALTER TABLE activities ADD COLUMN IF NOT EXISTS original_name VARCHAR(256)" )) + await conn.execute(text( + "ALTER TABLE activities ADD COLUMN IF NOT EXISTS active_spans JSON" + )) except Exception as e: print(f"activities.moving_time_s column migration skipped: {e}") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 200b891..2ab78c2 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -107,6 +107,11 @@ class Activity(Base): normalized_power = Column(Float, nullable=True) avg_speed_ms = Column(Float, nullable=True) max_speed_ms = Column(Float, nullable=True) + # When recording was paused/resumed (e.g. a long lunch break mid-ride) the + # device leaves a gap in the data stream. Stored as a list of active + # [start_ms, end_ms] epoch spans (only set when a >5min gap splits the + # recording into 2+ spans); null means one continuous recording. + active_spans = Column(JSON, nullable=True) avg_temperature_c = Column(Float, nullable=True) calories = Column(Float, nullable=True) training_stress_score = Column(Float, nullable=True) diff --git a/backend/app/services/fit_parser.py b/backend/app/services/fit_parser.py index 4a5950e..51cafc2 100644 --- a/backend/app/services/fit_parser.py +++ b/backend/app/services/fit_parser.py @@ -71,6 +71,42 @@ def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Option return None +# Recording gaps longer than this split an activity into separate "active" spans. +# Auto-pause at traffic lights produces sub-minute gaps; a genuine break (a meal +# stop on a long ride, etc.) leaves a multi-minute hole in the stream. +PAUSE_GAP_S = 300 + + +def _active_spans(points): + """From normalised data points, return a list of active [start_ms, end_ms] + epoch spans, splitting wherever the recording paused for more than + PAUSE_GAP_S. Returns None when the recording is one continuous span (the + common case) so the payload stays small.""" + ts = [] + for p in points: + t = p.get("timestamp") + if not t: + continue + try: + ts.append(int(datetime.fromisoformat(t).timestamp() * 1000)) + except (TypeError, ValueError): + continue + if len(ts) < 2: + return None + ts.sort() + gap_ms = PAUSE_GAP_S * 1000 + spans = [] + span_start = ts[0] + prev = ts[0] + for t in ts[1:]: + if t - prev > gap_ms: + spans.append([span_start, prev]) + span_start = t + prev = t + spans.append([span_start, prev]) + return spans if len(spans) > 1 else None + + def _bounding_box(coords): if not coords: return None @@ -241,9 +277,13 @@ def parse_fit_file(filepath: str) -> dict: elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")) # Timer time = time the device was actively recording (excludes auto/manual pauses). moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time")) + # When the FIT avgSpeed is missing/invalid we fall back to distance/time. + # Prefer moving (timer) time so the figure matches Garmin's moving-average + # semantics — using elapsed time badly understates pace on rides with a long + # mid-activity pause (e.g. a 5h elapsed / 1h48 moving ride). avg_speed = _sanitize_speed( get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"), - dist_m=total_dist, dur_s=elapsed_s, + dist_m=total_dist, dur_s=moving_s or elapsed_s, ) return { @@ -271,6 +311,7 @@ def parse_fit_file(filepath: str) -> dict: "total_training_effect")), "polyline": encoded_polyline, "bounding_box": bounding_box, + "active_spans": _active_spans(normalized_points), "source_type": "fit", "rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s), "data_points": normalized_points, @@ -358,6 +399,7 @@ def parse_gpx_file(filepath: str) -> dict: "max_speed_ms": None, "avg_temperature_c": None, "calories": None, "training_stress_score": None, "vo2max_estimate": None, "polyline": encoded_polyline, "bounding_box": bounding_box, + "active_spans": _active_spans(data_points), "source_type": "gpx", "rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration), "data_points": data_points, "laps": [], diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index e1ca172..d39d720 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -72,9 +72,14 @@ def _apply_garmin_summary(parsed: dict, summary: dict): parsed["moving_time_s"] = summary["moving"] if summary.get("elapsed") is not None: parsed["duration_s"] = summary["elapsed"] - dur = parsed.get("duration_s") - if parsed.get("distance_m") and dur: - parsed["avg_speed_ms"] = parsed["distance_m"] / dur + # Recompute average speed over moving (timer) time, not elapsed wall-clock — + # otherwise a long mid-activity pause (e.g. a meal stop on a ride) drags the + # displayed pace down across the whole break. Falls back to elapsed only when + # moving time is unavailable. + moving = parsed.get("moving_time_s") + denom = moving if (moving and moving > 0) else parsed.get("duration_s") + if parsed.get("distance_m") and denom: + parsed["avg_speed_ms"] = parsed["distance_m"] / denom @celery_app.task(bind=True, name="process_activity_file") @@ -174,6 +179,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str, normalized_power=parsed.get("normalized_power"), avg_speed_ms=parsed.get("avg_speed_ms"), max_speed_ms=parsed.get("max_speed_ms"), + active_spans=parsed.get("active_spans"), avg_temperature_c=parsed.get("avg_temperature_c"), calories=parsed.get("calories"), training_stress_score=parsed.get("training_stress_score"), diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 303428c..f992705 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -159,6 +159,21 @@ function ActivityRefLabel({ viewBox, sport }) { ) } +// A small ⏸ glyph drawn over the gap between two active spans, marking where +// the recording was paused mid-activity (e.g. a long lunch break on a ride). +function PauseRefLabel({ viewBox }) { + if (!viewBox) return null + const { x, y, width = 0, height = 0 } = viewBox + const cx = x + width / 2, cy = y + height / 2 + const barW = 2, barH = 8, gap = 1.5 + return ( + + + + + ) +} + // Activity time spans are drawn as a solid coloured band in a reserved strip // *below* the battery bars (the negative Y region) so they don't obscure the data. const ACTIVITY_BAND_BOTTOM = -18 @@ -232,17 +247,42 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) ))} - {(activities || []).map(a => { - const start = new Date(a.start_time).getTime() - const end = a.duration_s ? start + a.duration_s * 1000 : start - const x1 = nearestT(start), x2 = nearestT(end) - if (x1 == null || x2 == null) return null + {(activities || []).flatMap(a => { const color = sportColor(a.sport_type) - return ( - } /> - ) + const start = new Date(a.start_time).getTime() + const fullEnd = a.duration_s ? start + a.duration_s * 1000 : start + // active_spans (set only when a long pause splits the recording) + // draws one band per moving span with the pause shown as a gap; + // otherwise one continuous band as before. + const spans = (a.active_spans && a.active_spans.length > 1) + ? a.active_spans + : [[start, fullEnd]] + // Carry the sport icon on the longest span only. + let labelIdx = 0, labelLen = -1 + spans.forEach((s, i) => { const l = s[1] - s[0]; if (l > labelLen) { labelLen = l; labelIdx = i } }) + const els = [] + spans.forEach((s, i) => { + const x1 = nearestT(s[0]), x2 = nearestT(s[1]) + if (x1 != null && x2 != null) { + els.push( + : undefined} /> + ) + } + // Mark the paused stretch before the next span with a faint dashed strip + ⏸. + if (i < spans.length - 1) { + const g1 = nearestT(s[1]), g2 = nearestT(spans[i + 1][0]) + if (g1 != null && g2 != null && g1 !== g2) { + els.push( + } /> + ) + } + } + }) + return els })}