diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx
index 6a9fd0e..d8482a0 100644
--- a/frontend/src/components/activity/ActivityMap.jsx
+++ b/frontend/src/components/activity/ActivityMap.jsx
@@ -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])
diff --git a/frontend/src/components/activity/HRZoneBar.jsx b/frontend/src/components/activity/HRZoneBar.jsx
index ce795e5..516d639 100644
--- a/frontend/src/components/activity/HRZoneBar.jsx
+++ b/frontend/src/components/activity/HRZoneBar.jsx
@@ -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 (
{/* Stacked bar */}
@@ -34,6 +38,9 @@ export default function HRZoneBar({ zones }) {
{label}
{pct}%
+ {totalSeconds > 0 && (
+
{formatDuration(Math.round((pct / 100) * totalSeconds))}
+ )}
)
})}
diff --git a/frontend/src/components/activity/RouteLeaderboard.jsx b/frontend/src/components/activity/RouteLeaderboard.jsx
index b802753..b87eca3 100644
--- a/frontend/src/components/activity/RouteLeaderboard.jsx
+++ b/frontend/src/components/activity/RouteLeaderboard.jsx
@@ -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'
}`}
>
-
- {e.rank === 1 ? '๐' : e.rank}
+ |
+ {MEDALS[e.rank] || e.rank}
|
- |
- {e.rank === 1 ? '๐' : e.rank}
+ |
+ {MEDALS[e.rank] || e.rank}
|
|
{isPodium
- ? {MEDALS[seg.rank]}
+ ? {PLACE_MEDALS[seg.rank]}
: delta != null
? +{formatDuration(delta)}
: --}
diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx
index 81171ac..826b719 100644
--- a/frontend/src/pages/ActivityDetailPage.jsx
+++ b/frontend/src/pages/ActivityDetailPage.jsx
@@ -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) && (
Heart Rate Zones
-
+
)}
+ {/* Map and activity timeline side by side, each ~half width on large screens */}
+
{/* Map with controls โ only when the activity has a GPS track */}
{activity.polyline && activity.distance_m > 0 ? (
@@ -309,6 +303,7 @@ export default function ActivityDetailPage() {
No timeline data available for this activity
)}
+
{/* Laps ยท Routes ยท Segments โ on one row, each shrinking to fit and
expanding to fill the width when fewer are present. */}
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx
index f3ed7f0..503586b 100644
--- a/frontend/src/pages/HealthPage.jsx
+++ b/frontend/src/pages/HealthPage.jsx
@@ -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
+}
+
+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 (
No data
@@ -686,7 +696,7 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
))}
@@ -1017,10 +1027,17 @@ export default function HealthPage() {
- HRV (nightly avg)
+
+ HRV (nightly avg)
+
+ Unbalanced
+ Low
+
+
`${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 } },
diff --git a/frontend/src/utils/track.js b/frontend/src/utils/track.js
new file mode 100644
index 0000000..9bd8641
--- /dev/null
+++ b/frontend/src/utils/track.js
@@ -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
+}
|