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 ( +
+ {/* This activity's standing on the route */} + {current && ( +
+
+
This activity
+
+ {formatDuration(current.duration_s)} +
+
+
+
+ #{current.rank} of {total} +
+
+ {currentGap == null + ? 🏆 Fastest + : {currentGap} off fastest} +
+
+
+ )} + + + + + + + + + + + + {top.map((e) => { + const gap = gapLabel(e.gap_s) + return ( + + + + + + + ) + })} + {/* If this activity ranks outside the top 10, still surface its row. */} + {current && !inTop10 && ( + + + + + + + )} + +
#DateTimeΔ
+ {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) ?? '--'} +
+
+ ) +} diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index dae626a..0b6d8a3 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -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() { )} - {/* 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)) && (
{laps && laps.length > 0 && (
@@ -302,6 +309,12 @@ export default function ActivityDetailPage() {
)} + {routeBoard && routeBoard.top?.length > 0 && ( +
+

Route — Top 10 Times

+ +
+ )} {actSegments && actSegments.length > 0 && (

Segments