fix lap-bests distance matching + show PRs on activity page
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
</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 */}
|
||||
<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)} />
|
||||
|
||||
Reference in New Issue
Block a user