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