diff --git a/frontend/src/components/activity/LapTable.jsx b/frontend/src/components/activity/LapTable.jsx index cc0afdf..b2d6824 100644 --- a/frontend/src/components/activity/LapTable.jsx +++ b/frontend/src/components/activity/LapTable.jsx @@ -2,9 +2,28 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCade 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 hasBests = lapBests && Object.keys(lapBests).length > 0 + const modal = modalDistance(laps) + const prs = records || [] + const showLegend = hasBests || prs.length > 0 return (
@@ -23,22 +42,43 @@ export default function LapTable({ laps, sportType, lapBests }) { {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 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 ( - - + + - + {hasBests && ( )} {hasBests && ( )} @@ -58,6 +98,12 @@ export default function LapTable({ laps, sportType, lapBests }) { })}
{lap.lap_number}
+ + {lap.lap_number} + {isPR && 🥇} + + {formatDistance(lap.distance_m)}{formatDuration(lap.duration_s)}{formatDuration(lap.duration_s)}{best != null ? formatDuration(best) : '--'} - {delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`} + {delta == null ? '--' : isLapBest ? 🏆 : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`} {formatPace(lap.avg_speed_ms, sportType)}
+ {showLegend && ( +
+ {prs.length > 0 && 🥇 Personal best for the distance} + {hasBests && 🏆 Fastest time for this lap on the route} +
+ )}
) } diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 751ad68..5552c0a 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -335,7 +335,7 @@ export default function ActivityDetailPage() { {laps && laps.length > 0 && (

Laps

- +
)} {routeBoard && routeBoard.top?.length > 0 && (