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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user