ec87f68729
- Grey out trend ranges beyond available health history - Reject implausibly fast (vehicle) activities on upload with feedback - Add cancel button + cooperative cancellation for Garmin sync - Show daily steps prominently on the dashboard - Clear errors for malformed/empty upload ZIPs - Snap-target dot when drawing a segment on the map - Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS - Parse and display moving time (timer) vs elapsed; backfill task - Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5.9 KiB
React
146 lines
5.9 KiB
React
import { useState, Fragment } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import api from '../../utils/api'
|
|
import { formatDuration, formatDistance } from '../../utils/format'
|
|
|
|
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
|
|
|
// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard.
|
|
function gapLabel(gapS) {
|
|
if (gapS == null || gapS <= 0.5) return null
|
|
return `+${formatDuration(gapS)}`
|
|
}
|
|
|
|
// Top-10 leaderboard for a single segment, styled to match RouteLeaderboard.
|
|
function Leaderboard({ segmentId, activityId }) {
|
|
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>
|
|
|
|
const top = data.leaderboard.slice(0, 10)
|
|
const fastest = top[0].duration_s
|
|
return (
|
|
<table className="w-full text-sm mt-1 mb-2">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 border-b border-gray-800">
|
|
<th className="text-left pb-2 font-medium">#</th>
|
|
<th className="text-left pb-2 font-medium">Activity</th>
|
|
<th className="text-right pb-2 font-medium">Time</th>
|
|
<th className="text-right pb-2 font-medium">Δ</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{top.map((e) => {
|
|
const isCurrent = e.activity_id === activityId
|
|
const gap = gapLabel(e.duration_s - fastest)
|
|
return (
|
|
<tr
|
|
key={e.activity_id}
|
|
className={`border-b border-gray-800/50 transition-colors ${
|
|
isCurrent ? 'bg-emerald-500/15 hover:bg-emerald-500/20' : 'hover:bg-gray-800/30'
|
|
}`}
|
|
>
|
|
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
|
{e.rank === 1 ? '🏆' : e.rank}
|
|
</td>
|
|
<td className="py-2">
|
|
<Link
|
|
to={`/activities/${e.activity_id}`}
|
|
className={`hover:underline ${isCurrent ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
|
|
>
|
|
{e.activity_name}
|
|
</Link>
|
|
</td>
|
|
<td className={`py-2 text-right font-mono ${isCurrent ? 'text-emerald-300 font-semibold' : 'text-gray-200'}`}>
|
|
{formatDuration(e.duration_s)}
|
|
</td>
|
|
<td className="py-2 text-right font-mono text-gray-500">
|
|
{gap == null ? '--' : gap}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)
|
|
}
|
|
|
|
export default function SegmentsPanel({ segments, activityId }) {
|
|
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="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 border-b border-gray-800">
|
|
<th className="text-left pb-2 font-medium">Segment</th>
|
|
<th className="text-right pb-2 font-medium">This run</th>
|
|
<th className="text-right pb-2 font-medium">Best</th>
|
|
<th className="text-right pb-2 font-medium">Place</th>
|
|
<th className="pb-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{segments.map(seg => {
|
|
const isPodium = seg.rank && seg.rank <= 3
|
|
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
|
|
const isOpen = open === seg.segment_id
|
|
return (
|
|
<Fragment key={seg.segment_id}>
|
|
<tr
|
|
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
|
>
|
|
<td className="py-2">
|
|
<button
|
|
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
|
|
className="text-left text-gray-300 hover:text-white"
|
|
>
|
|
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
|
{seg.name}
|
|
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m)}</span>
|
|
</button>
|
|
</td>
|
|
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
|
{formatDuration(seg.duration_s)}
|
|
</td>
|
|
<td className="py-2 text-right font-mono text-gray-500">
|
|
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
|
</td>
|
|
<td className="py-2 text-right font-mono">
|
|
{isPodium
|
|
? <span title="Podium time on this activity" className="text-yellow-400">{MEDALS[seg.rank]}</span>
|
|
: delta != null
|
|
? <span className="text-gray-500">+{formatDuration(delta)}</span>
|
|
: <span className="text-gray-700">--</span>}
|
|
</td>
|
|
<td className="py-2 text-right">
|
|
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment">✕</button>
|
|
</td>
|
|
</tr>
|
|
{isOpen && (
|
|
<tr>
|
|
<td colSpan={5} className="bg-gray-950/40">
|
|
<Leaderboard segmentId={seg.segment_id} activityId={activityId} />
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|