Add medals, HRV status dots, smooth segment hover, side-by-side map/timeline, HR zone times
Build and push images / validate (push) Successful in 3s
Build and push images / build-frontend (push) Successful in 10s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s

- Silver/bronze medals (not just gold) on route & segment leaderboards
- Colour HRV nightly-avg trend dots: orange unbalanced, red low
- Project segment-hover dot smoothly along the track line (interpolated)
- Show map and activity timeline side by side, half width each
- Show time spent in each HR zone next to the percentage

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:44:20 +01:00
parent ec87f68729
commit af32a0bb7f
7 changed files with 83 additions and 35 deletions
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import L from 'leaflet'
import { sportColor } from '../../utils/format'
import { projectToTrack } from '../../utils/track'
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
@@ -78,17 +79,6 @@ const SEG_TARGET_ICON = L.divIcon({
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
// Nearest recorded GPS point to a lat/lng (squared planar distance is fine at
// these scales). Mirrors nearestDistance() in ActivityDetailPage.
function nearestPoint(points, lat, lng) {
let best = null, bestD = Infinity
for (const p of points) {
if (p.latitude == null || p.longitude == null) continue
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
if (d < bestD) { bestD = d; best = p }
}
return best
}
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
if (trackRef.current) {
@@ -190,7 +180,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
mapInstanceRef.current.on('mousemove', (e) => {
const pts = drawArgsRef.current.dataPoints
if (!clickRef.current || !pts?.length) return
const np = nearestPoint(pts, e.latlng.lat, e.latlng.lng)
const np = projectToTrack(pts, e.latlng.lat, e.latlng.lng)
if (!np) return
if (segTargetRef.current) {
segTargetRef.current.setLatLng([np.latitude, np.longitude])
@@ -1,3 +1,5 @@
import { formatDuration } from '../../utils/format'
const ZONE_CONFIG = [
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
@@ -6,7 +8,9 @@ const ZONE_CONFIG = [
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
]
export default function HRZoneBar({ zones }) {
// zones holds the % of time in each zone; multiply by the activity's active time
// to show the approximate time spent in each.
export default function HRZoneBar({ zones, totalSeconds }) {
return (
<div className="space-y-2">
{/* Stacked bar */}
@@ -34,6 +38,9 @@ export default function HRZoneBar({ zones }) {
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
<span className="text-xs font-medium text-white">{pct}%</span>
{totalSeconds > 0 && (
<span className="text-xs text-gray-500">{formatDuration(Math.round((pct / 100) * totalSeconds))}</span>
)}
</div>
)
})}
@@ -1,6 +1,8 @@
import { Link } from 'react-router-dom'
import { formatDuration, formatDate } from '../../utils/format'
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
// Compact +M:SS / +SS gap label (fastest effort shows nothing).
function gapLabel(gapS) {
if (gapS == null || gapS <= 0.5) return null
@@ -59,8 +61,8 @@ export default function RouteLeaderboard({ data }) {
: 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
{e.rank === 1 ? '🏆' : e.rank}
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
{MEDALS[e.rank] || e.rank}
</td>
<td className="py-2">
<Link
@@ -4,7 +4,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../../utils/api'
import { formatDuration, formatDistance } from '../../utils/format'
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard.
function gapLabel(gapS) {
@@ -44,8 +45,8 @@ function Leaderboard({ segmentId, activityId }) {
isCurrent ? 'bg-emerald-500/15 hover:bg-emerald-500/20' : 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
{e.rank === 1 ? '🏆' : e.rank}
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
{MEDALS[e.rank] || e.rank}
</td>
<td className="py-2">
<Link
@@ -119,7 +120,7 @@ export default function SegmentsPanel({ segments, activityId }) {
</td>
<td className="py-2 text-right font-mono">
{isPodium
? <span title="Podium time on this activity" className="text-yellow-400">{MEDALS[seg.rank]}</span>
? <span title="Podium time on this activity" className="text-yellow-400">{PLACE_MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-gray-500">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
+8 -13
View File
@@ -14,16 +14,7 @@ import {
formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format'
// Find the cumulative distance along the track nearest a clicked lat/lng.
function nearestDistance(points, lat, lng) {
let best = null, bestD = Infinity
for (const p of points) {
if (p.latitude == null || p.longitude == null || p.distance_m == null) continue
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
if (d < bestD) { bestD = d; best = p.distance_m }
}
return best
}
import { projectToTrack } from '../utils/track'
const METRICS = [
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
@@ -83,8 +74,9 @@ export default function ActivityDetailPage() {
const handleMapClick = ({ lat, lng }) => {
if (!segCreate || !dataPoints) return
const dist = nearestDistance(dataPoints, lat, lng)
if (dist == null) return
const proj = projectToTrack(dataPoints, lat, lng)
if (proj?.distance_m == null) return
const dist = proj.distance_m
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
}
@@ -162,10 +154,12 @@ export default function ActivityDetailPage() {
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} />
<HRZoneBar zones={activity.hr_zones} totalSeconds={activity.moving_time_s ?? activity.duration_s} />
</div>
)}
{/* Map and activity timeline side by side, each ~half width on large screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Map with controls — only when the activity has a GPS track */}
{activity.polyline && activity.distance_m > 0 ? (
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
@@ -309,6 +303,7 @@ export default function ActivityDetailPage() {
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
)}
</div>
</div>
{/* Laps · Routes · Segments — on one row, each shrinking to fit and
expanding to fill the width when fewer are present. */}
+20 -3
View File
@@ -649,7 +649,17 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
// ── Trend Charts ────────────────────────────────────────────────────────────
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
// Highlight problem days on a trend line by colouring the dot from a status field
// (e.g. HRV status): orange = unbalanced, red = low/poor. Other days get no dot.
const STATUS_DOT_COLORS = { unbalanced: '#f97316', low: '#ef4444', poor: '#ef4444' }
const statusDot = (statusKey) => (props) => {
const { cx, cy, payload } = props
const color = STATUS_DOT_COLORS[String(payload?.[statusKey] || '').toLowerCase()]
if (cx == null || cy == null || !color) return null
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
}
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
@@ -686,7 +696,7 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
))}
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
connectNulls={connectNulls} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
@@ -1017,10 +1027,17 @@ export default function HealthPage() {
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">HRV (nightly avg)</h3>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} /> Unbalanced</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#ef4444' }} /> Low</span>
</div>
</div>
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
selectedDate={selDateForCharts} onDayClick={handleDayClick}
statusDotKey="hrv_status"
referenceLines={[
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
+36
View File
@@ -0,0 +1,36 @@
// Project a lat/lng onto the activity's GPS track, returning the nearest point
// *along the line* (not just the nearest recorded sample) with an interpolated
// cumulative distance. This gives smooth snapping anywhere along the route rather
// than jumping between logged GPS points.
//
// Coordinates are treated as planar (lat/lng as y/x). At the scale of a single
// activity this is accurate enough for visual snapping and segment selection.
export function projectToTrack(points, lat, lng) {
const valid = points.filter(p => p.latitude != null && p.longitude != null)
if (valid.length === 0) return null
if (valid.length === 1) {
return { latitude: valid[0].latitude, longitude: valid[0].longitude, distance_m: valid[0].distance_m ?? 0 }
}
let best = null
let bestD = Infinity
for (let i = 0; i < valid.length - 1; i++) {
const a = valid[i]
const b = valid[i + 1]
const ax = a.longitude, ay = a.latitude
const dx = b.longitude - ax, dy = b.latitude - ay
const len2 = dx * dx + dy * dy
let t = len2 > 0 ? (((lng - ax) * dx + (lat - ay) * dy) / len2) : 0
t = Math.max(0, Math.min(1, t))
const px = ax + t * dx
const py = ay + t * dy
const d = (lat - py) ** 2 + (lng - px) ** 2
if (d < bestD) {
bestD = d
const da = a.distance_m, db = b.distance_m
const dist = (da != null && db != null) ? da + (db - da) * t : (da ?? db ?? null)
best = { latitude: py, longitude: px, distance_m: dist }
}
}
return best
}