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:
|
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")
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
Reference in New Issue
Block a user