fix lap-bests distance matching + show PRs on activity page
Build and push images / validate (push) Successful in 5s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s

This commit is contained in:
2026-06-16 11:38:12 +01:00
parent 16144d60b4
commit a168f594a7
2 changed files with 83 additions and 6 deletions
+62 -6
View File
@@ -216,20 +216,76 @@ async def get_lap_bests(
if not act.named_route_id: if not act.named_route_id:
return {} return {}
# Best per lap number across OTHER activities on the same route, so the # This activity's laps, so we know each lap's distance.
# comparison is meaningful (excluding this activity from its own benchmark). this_laps = (await db.execute(
rows = (await db.execute( select(ActivityLap.lap_number, ActivityLap.distance_m)
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s)) .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) .join(Activity, Activity.id == ActivityLap.activity_id)
.where( .where(
Activity.named_route_id == act.named_route_id, Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id, Activity.user_id == current_user.id,
Activity.id != activity_id, Activity.id != activity_id,
ActivityLap.duration_s.isnot(None), ActivityLap.duration_s.isnot(None),
ActivityLap.distance_m.isnot(None),
) )
.group_by(ActivityLap.lap_number)
)).all() )).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") @router.get("/{activity_id}/route-leaderboard")
+21
View File
@@ -66,6 +66,12 @@ export default function ActivityDetailPage() {
enabled: !!activity?.named_route_id, 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({ const { data: routeBoard } = useQuery({
queryKey: ['route-leaderboard', id], queryKey: ['route-leaderboard', id],
queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data), queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data),
@@ -132,6 +138,21 @@ export default function ActivityDetailPage() {
</div> </div>
</div> </div>
{/* Personal records set in this activity */}
{activityRecords && activityRecords.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-500 mr-1">🏆 Personal best{activityRecords.length > 1 ? 's' : ''} set here:</span>
{activityRecords.map(pr => (
<span
key={pr.distance_label}
className="text-xs px-2.5 py-1 rounded-full bg-yellow-500/10 text-yellow-400 border border-yellow-500/30 font-medium"
>
{pr.distance_label} · {formatDuration(pr.duration_s)}
</span>
))}
</div>
)}
{/* Stats — all on one row */} {/* Stats — all on one row */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} /> <StatCard label="Distance" value={formatDistance(activity.distance_m)} />