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:
@@ -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