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'])
|
||||
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -23,22 +42,43 @@ export default function LapTable({ laps, sportType, lapBests }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<tr
|
||||
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 ${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 && (
|
||||
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||
)}
|
||||
{hasBests && (
|
||||
<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 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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ export default function ActivityDetailPage() {
|
||||
{laps && laps.length > 0 && (
|
||||
<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>
|
||||
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
|
||||
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} records={activityRecords} />
|
||||
</div>
|
||||
)}
|
||||
{routeBoard && routeBoard.top?.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user