Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
- 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:
@@ -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": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user