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
co-authored by Claude Opus 4.8
parent e5feeb1178
commit bc437cce92
24 changed files with 1339 additions and 1445 deletions
+2 -117
View File
@@ -14,7 +14,7 @@ const DISTANCE_ORDER = [
'Half marathon', 'Marathon', '50k', '100k',
]
const TABS = ['Distance PRs', 'Route Records', 'Segment Records']
const TABS = ['Distance PRs', 'Route Records']
function DistancePRs() {
const [sport, setSport] = useState('running')
@@ -122,7 +122,7 @@ function DistancePRs() {
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM yy')} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={40} tickFormatter={formatDuration} reversed />
width={40} tickFormatter={formatDuration} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
@@ -212,120 +212,6 @@ function RouteRecords() {
)
}
function SegmentRecords() {
const [selectedRouteId, setSelectedRouteId] = useState(null)
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const { data: bests, isLoading } = useQuery({
queryKey: ['segment-bests', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segment-bests`).then(r => r.data),
enabled: !!selectedRouteId,
})
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
if (!routes?.length) return (
<p className="text-sm text-gray-600">
No named routes yet.{' '}
<Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link>
</p>
)
return (
<div className="space-y-4">
{/* Route tile grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
</button>
))}
</div>
{selectedRouteId && (
isLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : !bests?.length ? (
<p className="text-gray-600 text-sm">
No segments for this route.{' '}
<Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link>
</p>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="text-left px-4 py-3 font-medium">Segment</th>
<th className="text-right px-4 py-3 font-medium">Length</th>
<th className="text-right px-4 py-3 font-medium">Best time</th>
<th className="text-right px-4 py-3 font-medium">Runs</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{bests.map(b => (
<tr key={b.segment_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
<td className="px-4 py-3 text-gray-200">
{b.name}
{b.auto_generated && <span className="ml-2 text-xs text-gray-600">(auto)</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">
{formatDistance(b.end_distance_m - b.start_distance_m)}
</td>
<td className="px-4 py-3 text-right font-mono font-semibold">
{b.best_s != null
? <span className="text-yellow-400">{formatDuration(b.best_s)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">{b.count}</td>
<td className="px-4 py-3 text-right">
{b.best_activity_id && (
<Link to={`/activities/${b.best_activity_id}`} className="text-xs text-blue-400 hover:underline">
View
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
{theoreticalBest != null && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
)}
</div>
)
}
export default function RecordsPage() {
const [tab, setTab] = useState('Distance PRs')
@@ -351,7 +237,6 @@ export default function RecordsPage() {
{tab === 'Distance PRs' && <DistancePRs />}
{tab === 'Route Records' && <RouteRecords />}
{tab === 'Segment Records' && <SegmentRecords />}
</div>
)
}