lap awards: 🥇 PR rows highlighted, 🏆 route lap-best, no awards on partial laps
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s

This commit is contained in:
2026-06-16 12:38:29 +01:00
parent a168f594a7
commit c172bbe393
2 changed files with 55 additions and 9 deletions
+54 -8
View File
@@ -2,9 +2,28 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCade
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking']) const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
export default function LapTable({ laps, sportType, lapBests }) { // Most common lap distance (rounded to 100 m), used to tell "whole" laps from
// the short trailing fragment that auto-lapping leaves at the end of a run.
function modalDistance(laps) {
const counts = {}
for (const l of laps) {
if (l.distance_m == null) continue
const key = Math.round(l.distance_m / 100) * 100
counts[key] = (counts[key] || 0) + 1
}
let best = null, bestN = 0
for (const [k, n] of Object.entries(counts)) {
if (n > bestN) { bestN = n; best = Number(k) }
}
return best
}
export default function LapTable({ laps, sportType, lapBests, records }) {
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase()) const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
const hasBests = lapBests && Object.keys(lapBests).length > 0 const hasBests = lapBests && Object.keys(lapBests).length > 0
const modal = modalDistance(laps)
const prs = records || []
const showLegend = hasBests || prs.length > 0
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -23,22 +42,43 @@ export default function LapTable({ laps, sportType, lapBests }) {
</thead> </thead>
<tbody> <tbody>
{laps.map((lap) => { {laps.map((lap) => {
const best = hasBests ? lapBests[String(lap.lap_number)] : null // Only "whole" laps (within 10% of the typical lap distance) are
// eligible for awards — a 10 m trailing fragment isn't a real lap.
const isWhole = lap.distance_m != null && modal != null && lap.distance_m >= modal * 0.9
const best = hasBests && isWhole ? lapBests[String(lap.lap_number)] : null
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
const isBest = delta != null && delta <= 0.5 const isLapBest = delta != null && delta <= 0.5
// A personal record set on this lap: a standard-distance PR whose
// distance and time line up with this lap.
const isPR = isWhole && lap.duration_s != null && prs.some(r =>
r.distance_m != null && Math.abs(lap.distance_m - r.distance_m) <= r.distance_m * 0.05 &&
Math.abs(lap.duration_s - r.duration_s) <= 2
)
return ( return (
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"> <tr
<td className="py-2 text-gray-400">{lap.lap_number}</td> key={lap.lap_number}
className={`border-b transition-colors ${
isPR
? 'bg-yellow-500/10 border-yellow-500/30 hover:bg-yellow-500/15'
: 'border-gray-800/50 hover:bg-gray-800/30'
}`}
>
<td className="py-2 text-gray-400">
<span className="inline-flex items-center gap-1">
{lap.lap_number}
{isPR && <span title="Personal best">🥇</span>}
</span>
</td>
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td> <td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td> <td className={`py-2 text-right ${isPR || isLapBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
{hasBests && ( {hasBests && (
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td> <td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
)} )}
{hasBests && ( {hasBests && (
<td className={`py-2 text-right font-mono ${ <td className={`py-2 text-right font-mono ${
delta == null ? 'text-gray-700' : isBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400' delta == null ? 'text-gray-700' : isLapBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
}`}> }`}>
{delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`} {delta == null ? '--' : isLapBest ? <span title="Fastest on this route">🏆</span> : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</td> </td>
)} )}
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td> <td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
@@ -58,6 +98,12 @@ export default function LapTable({ laps, sportType, lapBests }) {
})} })}
</tbody> </tbody>
</table> </table>
{showLegend && (
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-3 text-xs text-gray-500">
{prs.length > 0 && <span>🥇 Personal best for the distance</span>}
{hasBests && <span>🏆 Fastest time for this lap on the route</span>}
</div>
)}
</div> </div>
) )
} }
+1 -1
View File
@@ -335,7 +335,7 @@ export default function ActivityDetailPage() {
{laps && laps.length > 0 && ( {laps && laps.length > 0 && (
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} /> <LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} records={activityRecords} />
</div> </div>
)} )}
{routeBoard && routeBoard.top?.length > 0 && ( {routeBoard && routeBoard.top?.length > 0 && (