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
+63
View File
@@ -229,6 +229,69 @@ async def get_lap_bests(
return {str(lap_number): best for lap_number, best in rows} return {str(lap_number): best for lap_number, best in rows}
@router.get("/{activity_id}/route-leaderboard")
async def get_route_leaderboard(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest-time leaderboard across all of this user's activities on the same
route. Returns this activity's rank/gap plus the top 10. Null if the activity
has no associated route (or no timed efforts to rank)."""
act = (await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)).scalar_one_or_none()
if not act:
raise HTTPException(status_code=404, detail="Activity not found")
if not act.named_route_id:
return None
rows = (await db.execute(
select(
Activity.id, Activity.name, Activity.start_time,
Activity.duration_s, Activity.distance_m, Activity.avg_heart_rate,
)
.where(
Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id,
Activity.duration_s.isnot(None),
)
.order_by(Activity.duration_s)
)).all()
if not rows:
return None
fastest_s = rows[0].duration_s
entries = []
current = None
for i, r in enumerate(rows):
entry = {
"rank": i + 1,
"activity_id": r.id,
"name": r.name,
"start_time": r.start_time,
"duration_s": r.duration_s,
"distance_m": r.distance_m,
"avg_heart_rate": r.avg_heart_rate,
"gap_s": r.duration_s - fastest_s,
"is_current": r.id == activity_id,
}
if entry["is_current"]:
current = entry
entries.append(entry)
return {
"route_id": act.named_route_id,
"total": len(entries),
"fastest_s": fastest_s,
"current": current,
"top": entries[:10],
}
@router.patch("/{activity_id}/name") @router.patch("/{activity_id}/name")
async def rename_activity( async def rename_activity(
activity_id: int, activity_id: int,
@@ -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>
)
}
+15 -2
View File
@@ -7,6 +7,7 @@ import MetricTimeline from '../components/activity/MetricTimeline'
import HRZoneBar from '../components/activity/HRZoneBar' import HRZoneBar from '../components/activity/HRZoneBar'
import LapTable from '../components/activity/LapTable' import LapTable from '../components/activity/LapTable'
import SegmentsPanel from '../components/activity/SegmentsPanel' import SegmentsPanel from '../components/activity/SegmentsPanel'
import RouteLeaderboard from '../components/activity/RouteLeaderboard'
import StatCard from '../components/ui/StatCard' import StatCard from '../components/ui/StatCard'
import { import {
formatDuration, formatDistance, formatPace, formatElevation, formatDuration, formatDistance, formatPace, formatElevation,
@@ -74,6 +75,12 @@ export default function ActivityDetailPage() {
enabled: !!activity?.named_route_id, enabled: !!activity?.named_route_id,
}) })
const { data: routeBoard } = useQuery({
queryKey: ['route-leaderboard', id],
queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data),
enabled: !!activity?.named_route_id,
})
const handleMapClick = ({ lat, lng }) => { const handleMapClick = ({ lat, lng }) => {
if (!segCreate || !dataPoints) return if (!segCreate || !dataPoints) return
const dist = nearestDistance(dataPoints, lat, lng) const dist = nearestDistance(dataPoints, lat, lng)
@@ -293,8 +300,8 @@ export default function ActivityDetailPage() {
)} )}
</div> </div>
{/* Laps + Segments side by side */} {/* Laps + Route leaderboard + Segments side by side */}
{((laps && laps.length > 0) || (actSegments && actSegments.length > 0)) && ( {((laps && laps.length > 0) || (actSegments && actSegments.length > 0) || (routeBoard && routeBoard.top?.length > 0)) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{laps && laps.length > 0 && ( {laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -302,6 +309,12 @@ export default function ActivityDetailPage() {
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} /> <LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
</div> </div>
)} )}
{routeBoard && routeBoard.top?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Route Top 10 Times</h3>
<RouteLeaderboard data={routeBoard} />
</div>
)}
{actSegments && actSegments.length > 0 && ( {actSegments && actSegments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>