Add per-route top-10 leaderboard to activity detail
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 9s

New /activities/{id}/route-leaderboard endpoint ranks the user's timed
efforts on the same route; frontend RouteLeaderboard card sits beside
Laps, showing this activity's time/rank/gap and the top 10 (current
effort highlighted green, also surfaced if outside the top 10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:37:37 +01:00
parent bdd5f80c7e
commit d350e9caea
3 changed files with 179 additions and 2 deletions
@@ -0,0 +1,101 @@
import { Link } from 'react-router-dom'
import { formatDuration, formatDate } from '../../utils/format'
// Compact +M:SS / +SS gap label (fastest effort shows nothing).
function gapLabel(gapS) {
if (gapS == null || gapS <= 0.5) return null
return `+${formatDuration(gapS)}`
}
export default function RouteLeaderboard({ data }) {
if (!data || !data.top || data.top.length === 0) return null
const { current, total, top } = data
const currentGap = current ? gapLabel(current.gap_s) : null
const inTop10 = current && current.rank <= 10
return (
<div className="overflow-x-auto">
{/* This activity's standing on the route */}
{current && (
<div className="mb-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 flex items-center justify-between gap-3">
<div>
<div className="text-xs text-emerald-300/80">This activity</div>
<div className="text-lg font-semibold text-emerald-300 font-mono">
{formatDuration(current.duration_s)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-gray-200">
#{current.rank} <span className="text-gray-500">of {total}</span>
</div>
<div className="text-xs">
{currentGap == null
? <span className="text-yellow-400">🏆 Fastest</span>
: <span className="text-gray-400">{currentGap} off fastest</span>}
</div>
</div>
</div>
)}
<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">#</th>
<th className="text-left pb-2 font-medium">Date</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 gap = gapLabel(e.gap_s)
return (
<tr
key={e.activity_id}
className={`border-b border-gray-800/50 transition-colors ${
e.is_current
? '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 ${e.is_current ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
>
{formatDate(e.start_time)}
</Link>
</td>
<td className={`py-2 text-right font-mono ${e.is_current ? '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>
)
})}
{/* If this activity ranks outside the top 10, still surface its row. */}
{current && !inTop10 && (
<tr className="border-t border-gray-700 bg-emerald-500/15">
<td className="py-2 text-emerald-300">{current.rank}</td>
<td className="py-2">
<span className="text-emerald-300 font-medium">{formatDate(current.start_time)}</span>
</td>
<td className="py-2 text-right font-mono text-emerald-300 font-semibold">
{formatDuration(current.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gapLabel(current.gap_s) ?? '--'}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}