Add per-route top-10 leaderboard to activity detail
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:
@@ -229,6 +229,69 @@ async def get_lap_bests(
|
||||
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")
|
||||
async def rename_activity(
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import MetricTimeline from '../components/activity/MetricTimeline'
|
||||
import HRZoneBar from '../components/activity/HRZoneBar'
|
||||
import LapTable from '../components/activity/LapTable'
|
||||
import SegmentsPanel from '../components/activity/SegmentsPanel'
|
||||
import RouteLeaderboard from '../components/activity/RouteLeaderboard'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatElevation,
|
||||
@@ -74,6 +75,12 @@ export default function ActivityDetailPage() {
|
||||
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 }) => {
|
||||
if (!segCreate || !dataPoints) return
|
||||
const dist = nearestDistance(dataPoints, lat, lng)
|
||||
@@ -293,8 +300,8 @@ export default function ActivityDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Laps + Segments side by side */}
|
||||
{((laps && laps.length > 0) || (actSegments && actSegments.length > 0)) && (
|
||||
{/* Laps + Route leaderboard + Segments side by side */}
|
||||
{((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">
|
||||
{laps && laps.length > 0 && (
|
||||
<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} />
|
||||
</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 && (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user