Batch 1: dashboard, maps, segments rewrite, health, sync UX
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s

Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
  weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar

Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
  1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows

Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:59:06 +01:00
parent e5feeb1178
commit bc437cce92
24 changed files with 1339 additions and 1445 deletions
@@ -24,6 +24,19 @@ const TILE_LAYERS = {
},
}
// Tile options tuned for smoother panning/zooming: keep a larger off-screen
// buffer of tiles and don't defer loads until the map is idle.
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
// Slow → fast colour ramp for speed-coloured routes.
const SPEED_STOPS = ['#3b82f6', '#22c55e', '#eab308', '#f97316', '#ef4444']
function speedColorIndex(speed, min, max) {
if (!(max > min)) return 1
const t = (speed - min) / (max - min)
return Math.min(SPEED_STOPS.length - 1, Math.max(0, Math.floor(t * SPEED_STOPS.length)))
}
function decodePolyline(encoded) {
const coords = []
let index = 0, lat = 0, lng = 0
@@ -39,43 +52,82 @@ function decodePolyline(encoded) {
return coords
}
function drawRoute(map, polyline, sportType, trackRef) {
const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
if (trackRef.current) {
trackRef.current.remove()
trackRef.current = null
}
if (!polyline) return
// Prefer the data-point track when colouring by speed; fall back to the encoded polyline.
const speedPts = (colorMode === 'speed' && dataPoints)
? dataPoints.filter(p => p.latitude != null && p.longitude != null)
: []
const group = L.layerGroup()
if (speedPts.length >= 2 && speedPts.some(p => p.speed_ms != null)) {
const speeds = speedPts.map(p => p.speed_ms).filter(s => s != null && s > 0)
speeds.sort((a, b) => a - b)
// Clamp the range to the 5th95th percentile so a couple of GPS spikes don't wash out the ramp.
const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0
const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1
// Group consecutive points into runs of the same colour bucket → one polyline per run.
let runStart = 0
let runIdx = speedColorIndex(speedPts[0].speed_ms ?? lo, lo, hi)
const flush = (end) => {
const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude])
if (coords.length >= 2) {
L.polyline(coords, { color: SPEED_STOPS[runIdx], weight: 3, opacity: 0.95 }).addTo(group)
}
}
for (let i = 1; i < speedPts.length; i++) {
const idx = speedColorIndex(speedPts[i].speed_ms ?? lo, lo, hi)
if (idx !== runIdx) {
flush(i) // include current point so runs join up
runStart = i
runIdx = idx
}
}
flush(speedPts.length - 1)
const coords = speedPts.map(p => [p.latitude, p.longitude])
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
group.addTo(map)
trackRef.current = group
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
return
}
// Solid single-colour route from the encoded polyline.
if (!polyline) return
const coords = decodePolyline(polyline)
if (!coords.length) return
trackRef.current = L.polyline(coords, {
color: sportColor(sportType),
weight: 3,
opacity: 0.9,
}).addTo(map)
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(map)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map)
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group)
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
group.addTo(map)
trackRef.current = group
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
}
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) {
const mapRef = useRef(null)
const mapInstanceRef = useRef(null)
const markerRef = useRef(null)
const trackRef = useRef(null)
const tileLayerRef = useRef(null)
const polylineRef = useRef(polyline)
const sportTypeRef = useRef(sportType)
const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode })
const clickRef = useRef(onMapClick)
useEffect(() => { polylineRef.current = polyline }, [polyline])
useEffect(() => { sportTypeRef.current = sportType }, [sportType])
drawArgsRef.current = { polyline, dataPoints, sportType, colorMode }
useEffect(() => { clickRef.current = onMapClick }, [onMapClick])
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return
@@ -83,13 +135,16 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
mapInstanceRef.current = L.map(mapRef.current, {
zoomControl: true,
attributionControl: true,
preferCanvas: true,
})
const tile = TILE_LAYERS.dark
tileLayerRef.current = L.tileLayer(tile.url, {
attribution: tile.attribution,
maxZoom: 19,
}).addTo(mapInstanceRef.current)
const tile = TILE_LAYERS.street
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
.addTo(mapInstanceRef.current)
mapInstanceRef.current.on('click', (e) => {
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
})
return () => {
mapInstanceRef.current?.remove()
@@ -99,19 +154,16 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
useEffect(() => {
if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
if (tileLayerRef.current) tileLayerRef.current.remove()
tileLayerRef.current = L.tileLayer(tile.url, {
attribution: tile.attribution,
maxZoom: 19,
}).addTo(mapInstanceRef.current)
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
.addTo(mapInstanceRef.current)
}, [mapType])
useEffect(() => {
if (!mapInstanceRef.current) return
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef)
}, [polyline, sportType])
drawRoute(mapInstanceRef.current, drawArgsRef.current, trackRef)
}, [polyline, sportType, colorMode, dataPoints])
useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
@@ -130,4 +182,4 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
}, [hoveredDistance, dataPoints])
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
}
}