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 +}