Fix pace sentinel, route map thumbnails, tiled segments, health/dashboard layout
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s

- Pace: FIT 0xFFFF sentinel (65.535 m/s) was stored as avg_speed_ms on every
  activity and lap; add _sanitize_speed() to parser falling back to dist/dur,
  plus a startup SQL migration that fixed 120 activities and 688 laps in-place
- Records: remove swimming from Distance PRs; Route Records rows are clickable
  (navigate to activity), View button removed, small SVG route map per row;
  Segment Records uses same tiled route-card layout as Segments page
- Segments: replace route dropdown with responsive tile grid showing SVG map
  thumbnails; selecting a tile reveals the segment management panel below
- RouteMiniMap: new pure-SVG component (no Leaflet) for route thumbnails,
  decodes polyline and normalises coords into a fixed viewBox
- Health: rename "Avg Heart Rate (day)" → "Heart Rate"; weight chart now
  filters to non-null rows and enables connectNulls + dots for sparse data
- Dashboard: 4-col layout at lg breakpoint so Body Battery sits between weekly
  chart and Health Today; Body Battery card gains a 24-hr sparkline from the
  values[] already present in the health summary response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:36:54 +01:00
parent 5f5551db27
commit 4a4cbdcc92
10 changed files with 267 additions and 95 deletions
+23 -6
View File
@@ -34,6 +34,16 @@ def _safe_float(val) -> Optional[float]:
return None
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
fv = _safe_float(val)
if fv is None or fv >= 65.0:
if dist_m and dur_s and float(dur_s) > 0:
return float(dist_m) / float(dur_s)
return None
return fv
def _bounding_box(coords):
if not coords:
return None
@@ -180,15 +190,19 @@ def parse_fit_file(filepath: str) -> dict:
normalized_laps = []
for i, lap in enumerate(laps):
ls = _to_dt(get(lap, "startTime", "start_time"))
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
"duration_s": _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time")),
"distance_m": _safe_float(get(lap, "totalDistance", "total_distance")),
"duration_s": lap_dur,
"distance_m": lap_dist,
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
"avg_speed_ms": _safe_float(get(lap, "avgSpeed", "avg_speed",
"enhancedAvgSpeed", "enhanced_avg_speed")),
"avg_speed_ms": _sanitize_speed(
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=lap_dist, dur_s=lap_dur,
),
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
})
@@ -209,8 +223,11 @@ 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": _safe_float(get(session_data, "avgSpeed", "avg_speed",
"enhancedAvgSpeed", "enhanced_avg_speed")),
"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")),
),
"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")),