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
@@ -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>
)
}