Batch 1: dashboard, maps, segments rewrite, health, sync UX
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:
@@ -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 5th–95th 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' }} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCade
|
||||
|
||||
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
|
||||
|
||||
export default function LapTable({ laps, sportType }) {
|
||||
export default function LapTable({ laps, sportType, lapBests }) {
|
||||
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
|
||||
const hasBests = lapBests && Object.keys(lapBests).length > 0
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -12,6 +13,8 @@ export default function LapTable({ laps, sportType }) {
|
||||
<th className="text-left pb-2 font-medium">Lap</th>
|
||||
<th className="text-right pb-2 font-medium">Distance</th>
|
||||
<th className="text-right pb-2 font-medium">Time</th>
|
||||
{hasBests && <th className="text-right pb-2 font-medium">Best</th>}
|
||||
{hasBests && <th className="text-right pb-2 font-medium">Δ</th>}
|
||||
<th className="text-right pb-2 font-medium">Pace</th>
|
||||
<th className="text-right pb-2 font-medium">Avg HR</th>
|
||||
<th className="text-right pb-2 font-medium">Cadence</th>
|
||||
@@ -19,25 +22,40 @@ export default function LapTable({ laps, sportType }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{laps.map((lap) => (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||
</td>
|
||||
{showPower && (
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||
{laps.map((lap) => {
|
||||
const best = hasBests ? lapBests[String(lap.lap_number)] : null
|
||||
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
|
||||
const isBest = delta != null && delta <= 0.5
|
||||
return (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
||||
{hasBests && (
|
||||
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||
)}
|
||||
{hasBests && (
|
||||
<td className={`py-2 text-right font-mono ${
|
||||
delta == null ? 'text-gray-700' : isBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||
</td>
|
||||
{showPower && (
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { formatPace, formatCadence } from '../../utils/format'
|
||||
|
||||
// Running cadence colour bands (steps per minute). Cadence is stored halved for
|
||||
// running, so spm = stored × 2.
|
||||
function cadenceColor(spm) {
|
||||
if (spm < 153) return '#ef4444' // red — slow
|
||||
if (spm < 164) return '#f97316' // orange — moderate
|
||||
if (spm < 174) return '#22c55e' // green — good recreational
|
||||
if (spm < 184) return '#3b82f6' // blue — experienced
|
||||
return '#a855f7' // purple — elite
|
||||
}
|
||||
|
||||
const renderCadenceDot = (props) => {
|
||||
const { cx, cy, payload } = props
|
||||
if (cx == null || cy == null || payload?.cadence == null) return null
|
||||
return <circle cx={cx} cy={cy} r={2} fill={cadenceColor(payload.cadence * 2)} />
|
||||
}
|
||||
|
||||
function downsample(points, maxPoints = 500) {
|
||||
if (points.length <= maxPoints) return points
|
||||
const step = Math.ceil(points.length / maxPoints)
|
||||
@@ -133,15 +149,23 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
stroke={metric.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls={false}
|
||||
/>
|
||||
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||
<>
|
||||
{/* 165 spm guide → 82.5 in stored (halved) units */}
|
||||
<ReferenceLine y={82.5} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1.5} />
|
||||
<Scatter dataKey="cadence" shape={renderCadenceDot} isAnimationActive={false} />
|
||||
</>
|
||||
) : (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
stroke={metric.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls={false}
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../../utils/api'
|
||||
import { formatDuration, formatDistance } from '../../utils/format'
|
||||
|
||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
|
||||
function Leaderboard({ segmentId }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['segment', segmentId],
|
||||
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
|
||||
})
|
||||
if (!data) return <p className="text-xs text-gray-600 py-2">Loading…</p>
|
||||
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2">No efforts yet — still matching.</p>
|
||||
return (
|
||||
<div className="space-y-0.5 py-1">
|
||||
{data.leaderboard.map((e, i) => (
|
||||
<div key={e.activity_id} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-5 text-right">{MEDALS[e.rank] || i + 1}</span>
|
||||
<span className="font-mono text-gray-200 w-14 text-right">{formatDuration(e.duration_s)}</span>
|
||||
<a href={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">{e.activity_name}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SegmentsPanel({ segments }) {
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(null)
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!confirm('Delete this segment?')) return
|
||||
await api.delete(`/segments/${id}`)
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 text-xs text-gray-600 uppercase tracking-wide">
|
||||
<span className="flex-1">Segment</span>
|
||||
<span className="w-14 text-right">This run</span>
|
||||
<span className="w-14 text-right">Best</span>
|
||||
<span className="w-10 text-right">Place</span>
|
||||
</div>
|
||||
{segments.map(seg => {
|
||||
const isPodium = seg.rank && seg.rank <= 3
|
||||
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
|
||||
return (
|
||||
<div key={seg.segment_id} className="border-b border-gray-800/40">
|
||||
<div className="flex items-center gap-3 py-1.5 text-sm">
|
||||
<button onClick={() => setOpen(open === seg.segment_id ? null : seg.segment_id)}
|
||||
className="flex-1 text-left text-gray-300 text-xs truncate hover:text-white">
|
||||
{seg.name}
|
||||
<span className="text-gray-600 ml-2">{formatDistance(seg.distance_m)}</span>
|
||||
</button>
|
||||
<span className={`font-mono text-xs w-14 text-right ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||
{formatDuration(seg.duration_s)}
|
||||
</span>
|
||||
<span className="font-mono text-xs w-14 text-right text-gray-500">
|
||||
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
||||
</span>
|
||||
<span className="w-10 text-right text-xs">
|
||||
{isPodium
|
||||
? <span title="New podium time on this activity">{MEDALS[seg.rank]}</span>
|
||||
: delta != null
|
||||
? <span className="text-red-400 font-mono">+{formatDuration(delta)}</span>
|
||||
: <span className="text-gray-700">--</span>}
|
||||
</span>
|
||||
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment">✕</button>
|
||||
</div>
|
||||
{open === seg.segment_id && <Leaderboard segmentId={seg.segment_id} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user