lap awards: 🥇 PR rows highlighted, 🏆 route lap-best, no awards on partial laps
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user