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
+179 -223
View File
@@ -2,92 +2,9 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import ActivityMap from '../components/activity/ActivityMap'
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
function formatSegDist(m) {
if (m == null) return '--'
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
function SegmentsPanel({ routeId, sportType }) {
const qc = useQueryClient()
const { data: segments } = useQuery({
queryKey: ['segments', routeId],
queryFn: () => api.get(`/routes/${routeId}/segments`).then(r => r.data),
})
const { data: bests } = useQuery({
queryKey: ['segment-bests', routeId],
queryFn: () => api.get(`/routes/${routeId}/segment-bests`).then(r => r.data),
})
const deleteSeg = useMutation({
mutationFn: segId => api.delete(`/routes/${routeId}/segments/${segId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['segments', routeId] })
qc.invalidateQueries({ queryKey: ['segment-bests', routeId] })
},
})
if (!segments?.length) return null
const bestMap = Object.fromEntries((bests || []).map(b => [b.segment_id, b]))
const kmSplits = segments.filter(s => s.name.startsWith('km '))
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
: null
const renderGroup = (group, title) => {
if (!group.length) return null
return (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{title}</p>
{group.map(seg => {
const best = bestMap[seg.id]
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className="text-gray-600 text-xs">{formatSegDist(seg.end_distance_m - seg.start_distance_m)}</span>
{best?.best_s != null ? (
<span className="font-mono text-yellow-400 text-xs w-14 text-right">{formatDuration(best.best_s)}</span>
) : (
<span className="text-gray-700 text-xs w-14 text-right">--</span>
)}
<button
onClick={() => { if (confirm(`Delete "${seg.name}"?`)) deleteSeg.mutate(seg.id) }}
className="text-gray-700 hover:text-red-400 transition-colors text-xs ml-1"
title="Delete segment"
></button>
</div>
)
})}
</div>
)
}
return (
<div className="border-t border-gray-800 pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-400">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
{renderGroup(kmSplits, '1km Splits')}
{renderGroup(hillsTurns, 'Hills & Turns')}
{theoreticalBest != null && (
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
}
// Decode Google encoded polyline to [[lat,lng], ...]
function decodePolyline(encoded) {
if (!encoded) return []
@@ -134,47 +51,37 @@ function RouteMap({ polyline, className = '', sportType = '' }) {
function routeSportStyle(sportType) {
const t = (sportType || '').toLowerCase()
if (t.includes('cycl') || t.includes('bike') || t.includes('ride'))
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400', color: '#f97316' }
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400' }
if (t.includes('run') || t.includes('jog') || t.includes('walk'))
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400', color: '#3b82f6' }
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400', color: '#6b7280' }
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400' }
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' }
}
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
const MEDALS = ['🥇', '🥈', '🥉']
function RouteDetail({ selected, setSelected }) {
const qc = useQueryClient()
const [merging, setMerging] = useState(false)
const [mergeTarget, setMergeTarget] = useState('')
const qc = useQueryClient()
const [editingName, setEditingName] = useState(false)
const [nameInput, setNameInput] = useState(selected.name)
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
// Sort by most completions first
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
const { data: routeActivities } = useQuery({
queryKey: ['route-activities', selected?.id],
queryKey: ['route-activities', selected.id],
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
enabled: !!selected,
})
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
const renameRoute = useMutation({
mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data),
onSuccess: updated => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
setSelected(updated)
setEditingName(false)
},
})
@@ -198,7 +105,161 @@ export default function RoutesPage() {
})
const fastest = routeActivities?.[0]
const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? []
const crTime = fastest?.duration_s
const otherRoutes = (routes || []).filter(r => r.id !== selected.id && r.sport_type === selected.sport_type)
return (
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex gap-4 items-start min-w-0">
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
{selected.reference_polyline
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
</div>
<div className="min-w-0">
{editingName ? (
<div className="flex items-center gap-2">
<input
value={nameInput}
onChange={e => setNameInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && nameInput.trim()) renameRoute.mutate(nameInput.trim()) }}
autoFocus
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button onClick={() => renameRoute.mutate(nameInput.trim())} disabled={!nameInput.trim() || renameRoute.isPending}
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-2 py-1 rounded-lg">Save</button>
<button onClick={() => { setEditingName(false); setNameInput(selected.name) }}
className="text-xs text-gray-400 hover:text-white px-1">Cancel</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white truncate">{selected.name}</h2>
<button onClick={() => { setNameInput(selected.name); setEditingName(true) }}
className="text-gray-500 hover:text-white text-sm" title="Rename route"></button>
</div>
)}
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Podium */}
{routeActivities?.length > 0 && (
<div className="grid grid-cols-3 gap-3">
{routeActivities.slice(0, 3).map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="bg-gray-800/50 hover:bg-gray-800 rounded-lg p-3 text-center transition-colors">
<p className="text-xl">{MEDALS[i]}</p>
<p className="font-mono text-lg font-bold text-white">{formatDuration(act.duration_s)}</p>
<p className="text-xs text-gray-500">{formatDate(act.start_time)}</p>
{i > 0 && crTime != null && (
<p className="text-xs text-red-400">+{formatDuration(act.duration_s - crTime)}</p>
)}
</Link>
))}
</div>
)}
{/* All completions */}
<h3 className="text-sm font-medium text-gray-400">All completions ({routeActivities?.length ?? 0})</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => {
const delta = crTime != null ? act.duration_s - crTime : null
return (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
</span>
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
: <span className="w-16" />}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
)
})}
</div>
</div>
)
}
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
const qc = useQueryClient()
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
// Sort by most completions first
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
},
})
return (
<div className="p-6 space-y-6">
@@ -256,7 +317,7 @@ export default function RoutesPage() {
</div>
)}
{/* Route tile grid */}
{/* Route tile grid — selected route's detail expands inline under its row */}
{routes?.length === 0 && !showCreate ? (
<div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p>
@@ -268,9 +329,9 @@ export default function RoutesPage() {
{sortedRoutes.map(route => {
const style = routeSportStyle(route.sport_type)
const isSelected = selected?.id === route.id
return (
return [
<button key={route.id}
onClick={() => { setSelected(isSelected ? null : route); setMerging(false) }}
onClick={() => setSelected(isSelected ? null : route)}
className={`text-left rounded-xl border p-2 transition-all ${
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
}`}>
@@ -279,121 +340,16 @@ export default function RoutesPage() {
<div className="flex items-center justify-between mt-0.5 gap-1">
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
{route.activity_count > 0 && (
<span className={`text-xs font-medium ${style.accent}`}>
{route.activity_count}×
</span>
<span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
)}
</div>
{route.auto_detected && (
<span className="text-xs text-gray-600">auto</span>
)}
</button>
)
{route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
</button>,
isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
]
})}
</div>
)}
{/* Route detail — shown below the tile grid when a route is selected */}
{selected && (
<div className="space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex gap-4 items-start">
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
<div>
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="mb-4 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
<button onClick={() => setMerging(false)}
className="text-gray-400 hover:text-white text-sm px-3 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Course record */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
</div>
</div>
)}
{/* Activity list */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All completions ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate && (
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
)}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
))}
</div>
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
</div>
</div>
)}
</div>
)
}