From a168f594a7331eccc7d00e9b1d99e12c72048213 Mon Sep 17 00:00:00 2001 From: owain Date: Tue, 16 Jun 2026 11:38:12 +0100 Subject: [PATCH] fix lap-bests distance matching + show PRs on activity page --- backend/app/api/activities.py | 68 +++++++++++++++++++++-- frontend/src/pages/ActivityDetailPage.jsx | 21 +++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index dc073ee..8ef49fb 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -216,20 +216,76 @@ async def get_lap_bests( if not act.named_route_id: return {} - # Best per lap number across OTHER activities on the same route, so the - # comparison is meaningful (excluding this activity from its own benchmark). - rows = (await db.execute( - select(ActivityLap.lap_number, func.min(ActivityLap.duration_s)) + # This activity's laps, so we know each lap's distance. + this_laps = (await db.execute( + select(ActivityLap.lap_number, ActivityLap.distance_m) + .where(ActivityLap.activity_id == activity_id) + )).all() + this_dist = {ln: d for ln, d in this_laps if d} + + # Laps from OTHER activities on the same route, so the comparison excludes + # this activity from its own benchmark. + other_laps = (await db.execute( + select(ActivityLap.lap_number, ActivityLap.distance_m, ActivityLap.duration_s) .join(Activity, Activity.id == ActivityLap.activity_id) .where( Activity.named_route_id == act.named_route_id, Activity.user_id == current_user.id, Activity.id != activity_id, ActivityLap.duration_s.isnot(None), + ActivityLap.distance_m.isnot(None), ) - .group_by(ActivityLap.lap_number) )).all() - return {str(lap_number): best for lap_number, best in rows} + + # Best (fastest) time per lap number, comparing only laps that cover roughly + # the same distance (±5%). Without this, a short/partial lap that happens to + # share a lap number (e.g. a 461 m "lap 5" vs this activity's 1 km lap 5) + # would win as "best" and produce a nonsensical benchmark. + bests: dict[str, float] = {} + for ln, dist, dur in other_laps: + target = this_dist.get(ln) + if target is None or abs(dist - target) > target * 0.05: + continue + key = str(ln) + if key not in bests or dur < bests[key]: + bests[key] = dur + return bests + + +@router.get("/{activity_id}/records") +async def get_activity_records( + activity_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Current personal records that were set in this activity (e.g. a 1 km PR), + so the activity page can flag which records it holds.""" + act = (await db.execute( + select(Activity.id).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") + + rows = (await db.execute( + select(PersonalRecord) + .where( + PersonalRecord.user_id == current_user.id, + PersonalRecord.activity_id == activity_id, + PersonalRecord.is_current_record == True, + ) + .order_by(PersonalRecord.distance_m) + )).scalars().all() + return [ + { + "distance_label": r.distance_label, + "distance_m": r.distance_m, + "duration_s": r.duration_s, + } + for r in rows + ] @router.get("/{activity_id}/route-leaderboard") diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 4570d46..751ad68 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -66,6 +66,12 @@ export default function ActivityDetailPage() { enabled: !!activity?.named_route_id, }) + const { data: activityRecords } = useQuery({ + queryKey: ['activity-records', id], + queryFn: () => api.get(`/activities/${id}/records`).then(r => r.data), + enabled: !!activity, + }) + const { data: routeBoard } = useQuery({ queryKey: ['route-leaderboard', id], queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data), @@ -132,6 +138,21 @@ export default function ActivityDetailPage() { + {/* Personal records set in this activity */} + {activityRecords && activityRecords.length > 0 && ( +
+ 🏆 Personal best{activityRecords.length > 1 ? 's' : ''} set here: + {activityRecords.map(pr => ( + + {pr.distance_label} · {formatDuration(pr.duration_s)} + + ))} +
+ )} + {/* Stats — all on one row */}