Add medals, HRV status dots, smooth segment hover, side-by-side map/timeline, HR zone times
- 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:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { sportColor } from '../../utils/format'
|
import { sportColor } from '../../utils/format'
|
||||||
|
import { projectToTrack } from '../../utils/track'
|
||||||
|
|
||||||
delete L.Icon.Default.prototype._getIconUrl
|
delete L.Icon.Default.prototype._getIconUrl
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
@@ -78,17 +79,6 @@ const SEG_TARGET_ICON = L.divIcon({
|
|||||||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
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) {
|
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
|
||||||
if (trackRef.current) {
|
if (trackRef.current) {
|
||||||
@@ -190,7 +180,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
|||||||
mapInstanceRef.current.on('mousemove', (e) => {
|
mapInstanceRef.current.on('mousemove', (e) => {
|
||||||
const pts = drawArgsRef.current.dataPoints
|
const pts = drawArgsRef.current.dataPoints
|
||||||
if (!clickRef.current || !pts?.length) return
|
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 (!np) return
|
||||||
if (segTargetRef.current) {
|
if (segTargetRef.current) {
|
||||||
segTargetRef.current.setLatLng([np.latitude, np.longitude])
|
segTargetRef.current.setLatLng([np.latitude, np.longitude])
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatDuration } from '../../utils/format'
|
||||||
|
|
||||||
const ZONE_CONFIG = [
|
const ZONE_CONFIG = [
|
||||||
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
|
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
|
||||||
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
|
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
|
||||||
@@ -6,7 +8,9 @@ const ZONE_CONFIG = [
|
|||||||
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
|
{ 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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Stacked bar */}
|
{/* Stacked bar */}
|
||||||
@@ -34,6 +38,9 @@ export default function HRZoneBar({ zones }) {
|
|||||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
<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 text-gray-400">{label}</span>
|
||||||
<span className="text-xs font-medium text-white">{pct}%</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { formatDuration, formatDate } from '../../utils/format'
|
import { formatDuration, formatDate } from '../../utils/format'
|
||||||
|
|
||||||
|
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
||||||
|
|
||||||
// Compact +M:SS / +SS gap label (fastest effort shows nothing).
|
// Compact +M:SS / +SS gap label (fastest effort shows nothing).
|
||||||
function gapLabel(gapS) {
|
function gapLabel(gapS) {
|
||||||
if (gapS == null || gapS <= 0.5) return null
|
if (gapS == null || gapS <= 0.5) return null
|
||||||
@@ -59,8 +61,8 @@ export default function RouteLeaderboard({ data }) {
|
|||||||
: 'hover:bg-gray-800/30'
|
: 'hover:bg-gray-800/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||||
{e.rank === 1 ? '🏆' : e.rank}
|
{MEDALS[e.rank] || e.rank}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import { formatDuration, formatDistance } from '../../utils/format'
|
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.
|
// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard.
|
||||||
function gapLabel(gapS) {
|
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'
|
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'}`}>
|
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||||
{e.rank === 1 ? '🏆' : e.rank}
|
{MEDALS[e.rank] || e.rank}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<Link
|
<Link
|
||||||
@@ -119,7 +120,7 @@ export default function SegmentsPanel({ segments, activityId }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-right font-mono">
|
<td className="py-2 text-right font-mono">
|
||||||
{isPodium
|
{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
|
: delta != null
|
||||||
? <span className="text-gray-500">+{formatDuration(delta)}</span>
|
? <span className="text-gray-500">+{formatDuration(delta)}</span>
|
||||||
: <span className="text-gray-700">--</span>}
|
: <span className="text-gray-700">--</span>}
|
||||||
|
|||||||
@@ -14,16 +14,7 @@ import {
|
|||||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
|
||||||
// Find the cumulative distance along the track nearest a clicked lat/lng.
|
import { projectToTrack } from '../utils/track'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const METRICS = [
|
const METRICS = [
|
||||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||||
@@ -83,8 +74,9 @@ export default function ActivityDetailPage() {
|
|||||||
|
|
||||||
const handleMapClick = ({ lat, lng }) => {
|
const handleMapClick = ({ lat, lng }) => {
|
||||||
if (!segCreate || !dataPoints) return
|
if (!segCreate || !dataPoints) return
|
||||||
const dist = nearestDistance(dataPoints, lat, lng)
|
const proj = projectToTrack(dataPoints, lat, lng)
|
||||||
if (dist == null) return
|
if (proj?.distance_m == null) return
|
||||||
|
const dist = proj.distance_m
|
||||||
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
|
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) && (
|
{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">
|
<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>
|
<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>
|
</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 */}
|
{/* Map with controls — only when the activity has a GPS track */}
|
||||||
{activity.polyline && activity.distance_m > 0 ? (
|
{activity.polyline && activity.distance_m > 0 ? (
|
||||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
<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>
|
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Laps · Routes · Segments — on one row, each shrinking to fit and
|
{/* Laps · Routes · Segments — on one row, each shrinking to fit and
|
||||||
expanding to fill the width when fewer are present. */}
|
expanding to fill the width when fewer are present. */}
|
||||||
|
|||||||
@@ -649,7 +649,17 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
|
|
||||||
// ── Trend Charts ────────────────────────────────────────────────────────────
|
// ── 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)
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
if (!vals.length) return (
|
if (!vals.length) return (
|
||||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
<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}
|
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||||
fill={`url(#grad-${dataKey})`}
|
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} />
|
connectNulls={connectNulls} isAnimationActive={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -1017,10 +1027,17 @@ export default function HealthPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<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"
|
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||||
formatter={v => `${Math.round(v)} ms`}
|
formatter={v => `${Math.round(v)} ms`}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
|
statusDotKey="hrv_status"
|
||||||
referenceLines={[
|
referenceLines={[
|
||||||
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
{ 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 } },
|
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user