Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m57s
Build and push images / build-worker (push) Successful in 50s
Build and push images / build-frontend (push) Successful in 24s

- Grey out trend ranges beyond available health history
- Reject implausibly fast (vehicle) activities on upload with feedback
- Add cancel button + cooperative cancellation for Garmin sync
- Show daily steps prominently on the dashboard
- Clear errors for malformed/empty upload ZIPs
- Snap-target dot when drawing a segment on the map
- Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS
- Parse and display moving time (timer) vs elapsed; backfill task
- Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 19:41:56 +01:00
parent 057eb9391a
commit ec87f68729
17 changed files with 569 additions and 132 deletions
+47 -10
View File
@@ -44,6 +44,33 @@ def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
return fv
# Conservative average-speed ceilings (m/s) above which an activity was almost
# certainly recorded in a vehicle rather than under human power. Sports not
# listed fall back to the generous default.
_VEHICLE_SPEED_CEILINGS = {
"running": 8.0, # ~28.8 km/h — well above elite sprint pace sustained
"walking": 8.0,
"hiking": 8.0,
"cycling": 22.0, # ~79 km/h — beyond sustained amateur cycling
}
_VEHICLE_SPEED_DEFAULT = 25.0 # ~90 km/h
def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Optional[str]:
"""Return a human-readable reason if the average speed is implausibly fast for
the sport (i.e. the 'activity' looks like car/vehicle travel), else None."""
speed = _safe_float(avg_speed_ms)
if speed is None and dist_m and dur_s and float(dur_s) > 0:
speed = float(dist_m) / float(dur_s)
if speed is None or speed <= 0:
return None
ceiling = _VEHICLE_SPEED_CEILINGS.get(sport_type, _VEHICLE_SPEED_DEFAULT)
if speed > ceiling:
return (f"Looks like vehicle travel — average speed {speed * 3.6:.0f} km/h "
f"exceeds the plausible limit for {sport_type}")
return None
def _bounding_box(coords):
if not coords:
return None
@@ -210,12 +237,22 @@ def parse_fit_file(filepath: str) -> dict:
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
total_dist = _safe_float(get(session_data, "totalDistance", "total_distance"))
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"))
avg_speed = _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=total_dist, dur_s=elapsed_s,
)
return {
"name": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
"distance_m": total_dist,
"duration_s": elapsed_s,
"moving_time_s": moving_s,
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
@@ -223,11 +260,7 @@ def parse_fit_file(filepath: str) -> dict:
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
),
"avg_speed_ms": avg_speed,
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
@@ -239,6 +272,7 @@ def parse_fit_file(filepath: str) -> dict:
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
"data_points": normalized_points,
"laps": normalized_laps,
}
@@ -310,20 +344,23 @@ def parse_gpx_file(filepath: str) -> dict:
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = track.type.lower() if track.type else "running"
gpx_avg_speed = (total_dist / duration) if (total_dist and duration) else None
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport, "start_time": start_time_str,
"distance_m": total_dist, "duration_s": duration,
"distance_m": total_dist, "duration_s": duration, "moving_time_s": None,
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None, "avg_power": None, "normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
"avg_speed_ms": gpx_avg_speed,
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box,
"source_type": "gpx", "data_points": data_points, "laps": [],
"source_type": "gpx",
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
"data_points": data_points, "laps": [],
}