diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 30a4960..477b4cf 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -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, diff --git a/frontend/src/components/activity/RouteLeaderboard.jsx b/frontend/src/components/activity/RouteLeaderboard.jsx new file mode 100644 index 0000000..b802753 --- /dev/null +++ b/frontend/src/components/activity/RouteLeaderboard.jsx @@ -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 ( +
| # | +Date | +Time | +Δ | +
|---|---|---|---|
| + {e.rank === 1 ? '🏆' : e.rank} + | ++ + {formatDate(e.start_time)} + + | ++ {formatDuration(e.duration_s)} + | ++ {gap == null ? '--' : gap} + | +
| {current.rank} | ++ {formatDate(current.start_time)} + | ++ {formatDuration(current.duration_s)} + | ++ {gapLabel(current.gap_s) ?? '--'} + | +