From 0aa27713ca0361e2b8200b945bc7d48e2347f88a Mon Sep 17 00:00:00 2001 From: owain Date: Mon, 8 Jun 2026 20:39:26 +0100 Subject: [PATCH] Fix follow-ups: lap bests, segments, charts, dashboard health - Lap bests: compare against OTHER activities on the route (exclude self), so single-activity routes no longer show every lap as "best" - Segment create: POST to trailing-slash URL (was a 307 that dropped the body); surface errors in the UI - PR splits: scale GPS distance stream to the activity's official distance so over-measured GPS no longer yields bogus split PRs - Speed route colours: red->orange->green->blue->purple (slow->fast) with smooth interpolation + a Slow/Fast gradient key under the map - Health body battery: snap activity highlight to the categorical axis; white tooltip text + % suffix - Health weight: y-min = lowest weight - 20kg; st/lb hover shows total lbs too - Health sleep: move 8h/avg reference labels into the right margin - Dashboard: Health-today pulls latest non-null values (sleep score, VO2 max); body battery tile renders a condensed colour-graded intraday graph Co-Authored-By: Claude Opus 4.8 --- backend/app/api/activities.py | 3 + backend/app/workers/tasks.py | 13 ++ .../src/components/activity/ActivityMap.jsx | 43 +++++-- frontend/src/pages/ActivityDetailPage.jsx | 32 +++-- frontend/src/pages/DashboardPage.jsx | 111 ++++++++++-------- frontend/src/pages/HealthPage.jsx | 50 ++++---- 6 files changed, 159 insertions(+), 93 deletions(-) diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 6dcea5e..2e8275b 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -213,12 +213,15 @@ async def get_lap_bests( if not act.named_route_id: return {} + # Best per lap number across OTHER activities on the same route, so the + # comparison is meaningful (excluding this activity from its own benchmark). rows = (await db.execute( select(ActivityLap.lap_number, func.min(ActivityLap.duration_s)) .join(Activity, Activity.id == ActivityLap.activity_id) .where( Activity.named_route_id == act.named_route_id, Activity.user_id == current_user.id, + Activity.id != activity_id, ActivityLap.duration_s.isnot(None), ) .group_by(ActivityLap.lap_number) diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 72e23ce..433c72c 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -380,6 +380,19 @@ def compute_personal_records(activity_id: int, user_id: int, parsed: dict): start_time_str = parsed.get("start_time") start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc) + # GPS can over/under-measure relative to the activity's official distance + # (e.g. a 5 km run whose GPS track sums to 5.8 km), which would otherwise + # produce a bogus "best 5 km" split. Scale the distance stream so its max + # matches the recorded total before computing splits. + if total_dist > 0 and data_points: + gps_max = max((p.get("distance_m") or 0) for p in data_points) + if gps_max > 0 and abs(gps_max - total_dist) / total_dist > 0.02: + factor = total_dist / gps_max + data_points = [ + {**p, "distance_m": p["distance_m"] * factor} if p.get("distance_m") is not None else p + for p in data_points + ] + best_splits = compute_best_splits(data_points, total_dist) with SyncSessionLocal() as db: diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx index d209f52..3e4216e 100644 --- a/frontend/src/components/activity/ActivityMap.jsx +++ b/frontend/src/components/activity/ActivityMap.jsx @@ -28,13 +28,27 @@ const TILE_LAYERS = { // buffer of tiles and don't defer loads until the map is idle. const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false } -// Slow → fast colour ramp for speed-coloured routes. -const SPEED_STOPS = ['#3b82f6', '#22c55e', '#eab308', '#f97316', '#ef4444'] +// Slow → fast colour ramp for speed-coloured routes (red → purple). +export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7'] -function speedColorIndex(speed, min, max) { - if (!(max > min)) return 1 - const t = (speed - min) / (max - min) - return Math.min(SPEED_STOPS.length - 1, Math.max(0, Math.floor(t * SPEED_STOPS.length))) +// CSS gradient string for the speed legend. +export const SPEED_GRADIENT = `linear-gradient(to right, ${SPEED_STOPS.join(', ')})` + +const SPEED_LEVELS = 24 // quantisation steps → smooth gradient while limiting layer count + +function lerpColor(c1, c2, t) { + const a = parseInt(c1.slice(1), 16), b = parseInt(c2.slice(1), 16) + const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t) + const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t) + const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t) + return `#${((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1)}` +} + +function rampColor(t) { + t = Math.max(0, Math.min(1, t)) + const seg = t * (SPEED_STOPS.length - 1) + const i = Math.min(SPEED_STOPS.length - 2, Math.floor(seg)) + return lerpColor(SPEED_STOPS[i], SPEED_STOPS[i + 1], seg - i) } function decodePolyline(encoded) { @@ -77,21 +91,26 @@ function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0 const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1 - // Group consecutive points into runs of the same colour bucket → one polyline per run. + const levelOf = (s) => { + const t = (hi > lo) ? (((s ?? lo) - lo) / (hi - lo)) : 0.5 + return Math.round(Math.max(0, Math.min(1, t)) * SPEED_LEVELS) + } + + // Group consecutive points into runs of the same colour level → one polyline per run. let runStart = 0 - let runIdx = speedColorIndex(speedPts[0].speed_ms ?? lo, lo, hi) + let runLevel = levelOf(speedPts[0].speed_ms) const flush = (end) => { const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude]) if (coords.length >= 2) { - L.polyline(coords, { color: SPEED_STOPS[runIdx], weight: 3, opacity: 0.95 }).addTo(group) + L.polyline(coords, { color: rampColor(runLevel / SPEED_LEVELS), weight: 3, opacity: 0.95 }).addTo(group) } } for (let i = 1; i < speedPts.length; i++) { - const idx = speedColorIndex(speedPts[i].speed_ms ?? lo, lo, hi) - if (idx !== runIdx) { + const level = levelOf(speedPts[i].speed_ms) + if (level !== runLevel) { flush(i) // include current point so runs join up runStart = i - runIdx = idx + runLevel = level } } flush(speedPts.length - 1) diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index b60c9e0..dae626a 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -2,7 +2,7 @@ import { useParams } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState, useMemo } from 'react' import api from '../utils/api' -import ActivityMap from '../components/activity/ActivityMap' +import ActivityMap, { SPEED_GRADIENT } from '../components/activity/ActivityMap' import MetricTimeline from '../components/activity/MetricTimeline' import HRZoneBar from '../components/activity/HRZoneBar' import LapTable from '../components/activity/LapTable' @@ -81,16 +81,22 @@ export default function ActivityDetailPage() { setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }])) } + const [segError, setSegError] = useState('') const createSegment = async () => { const [a, b] = segPoints - await api.post('/segments', { - name: segName.trim() || 'Segment', - activity_id: Number(id), - start_distance_m: a.distance_m, - end_distance_m: b.distance_m, - }) - setSegCreate(false); setSegPoints([]); setSegName('') - qc.invalidateQueries({ queryKey: ['activity-segments', id] }) + setSegError('') + try { + await api.post('/segments/', { + name: segName.trim() || 'Segment', + activity_id: Number(id), + start_distance_m: a.distance_m, + end_distance_m: b.distance_m, + }) + setSegCreate(false); setSegPoints([]); setSegName('') + qc.invalidateQueries({ queryKey: ['activity-segments', id] }) + } catch (e) { + setSegError(e.response?.data?.detail || 'Failed to create segment') + } } const toggleMetric = (key) => { @@ -230,6 +236,7 @@ export default function ActivityDetailPage() { {segPoints.length > 0 && ( )} + {segError && {segError}} )}
@@ -243,6 +250,13 @@ export default function ActivityDetailPage() { onMapClick={segCreate ? handleMapClick : undefined} />
+ {colorMode === 'speed' && ( +
+ Slow +
+ Fast +
+ )}
{/* Metric timeline */} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 752fc94..32a7664 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,6 +1,7 @@ import { Link, useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts' +import { useMemo } from 'react' +import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns' import api from '../utils/api' import StatCard from '../components/ui/StatCard' @@ -27,53 +28,45 @@ function bbLevelColor(level) { return '#ef4444' } -function MiniBodyBattery({ bb }) { - if (!bb?.end_level && !bb?.charged) return null - const { charged, drained, start_level, end_level, values } = bb - const color = bbLevelColor(end_level) - const sparkData = Array.isArray(values) - ? values.map(([ts, level]) => ({ ts, level })) - : [] +function MiniBodyBattery({ bb, hires }) { + const data = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level })) + const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level + const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level + const hasGraph = data.length >= 2 + return ( -
+

Body Battery

View →
- {end_level != null && ( - {Math.round(end_level)} - )} - {charged != null && ( - +{charged} - )} - {drained != null && ( - -{drained} + {peak != null && ( + {Math.round(peak)} )} + {charged != null && +{charged}} + {drained != null && -{drained}} + {end_level != null && now {Math.round(end_level)}}
- {start_level != null && end_level != null && ( -

{start_level} → {end_level} today

- )} - {sparkData.length >= 2 && ( -
- - - - - - - - - + {hasGraph ? ( +
+ + + format(new Date(ts), 'HH:mm')} - formatter={v => [`${Math.round(v)}`, 'Battery']} + formatter={v => [`${Math.round(v)}%`, 'Battery']} /> - + + {data.map((d, i) => )} + +
+ ) : ( +

No body battery data today

)}
) @@ -148,9 +141,32 @@ export default function DashboardPage() { }).then(r => r.data), }) - const { data: healthSummary } = useQuery({ - queryKey: ['health-summary'], - queryFn: () => api.get('/health-metrics/summary').then(r => r.data), + const { data: recentHealth } = useQuery({ + queryKey: ['health-metrics', 'dash'], + queryFn: () => api.get('/health-metrics/', { params: { limit: 365 } }).then(r => r.data), + }) + + // Latest available (non-null) value per metric — Garmin updates some fields + // less often than daily, so "today" can be sparse. + const health = useMemo(() => { + const rows = [...(recentHealth || [])].sort((a, b) => new Date(b.date) - new Date(a.date)) + const pick = f => rows.find(d => d[f] != null)?.[f] ?? null + return { + date: rows[0]?.date ?? null, + resting_hr: pick('resting_hr'), + sleep_duration_s: pick('sleep_duration_s'), + hrv_nightly_avg: pick('hrv_nightly_avg'), + sleep_score: pick('sleep_score'), + steps: pick('steps'), + vo2max: pick('vo2max'), + avg_stress: pick('avg_stress'), + } + }, [recentHealth]) + + const { data: intraday } = useQuery({ + queryKey: ['health-intraday-dash', health.date], + queryFn: () => api.get('/health-metrics/intraday', { params: { date: health.date } }).then(r => r.data), + enabled: !!health.date, }) const { data: records } = useQuery({ @@ -163,7 +179,6 @@ export default function DashboardPage() { queryFn: () => api.get('/activities/stats/ytd').then(r => r.data), }) - const latest = healthSummary?.latest const featured = recentActivities?.[0] return ( @@ -176,8 +191,8 @@ export default function DashboardPage() {
- - + +
@@ -187,19 +202,19 @@ export default function DashboardPage() {
- +

Health today

- {latest ? ( + {health.date ? ( <> {[ - ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'], - ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'], - ['Steps', latest.steps?.toLocaleString() ?? '--'], - ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'], - ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'], + ['HRV', health.hrv_nightly_avg ? `${Math.round(health.hrv_nightly_avg)} ms` : '--'], + ['Sleep score', health.sleep_score ? Math.round(health.sleep_score) : '--'], + ['Steps', health.steps?.toLocaleString() ?? '--'], + ['VO2 Max', health.vo2max ? health.vo2max.toFixed(1) : '--'], + ['Stress', health.avg_stress ? Math.round(health.avg_stress) : '--'], ].map(([label, val]) => (
{label} diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index e2508e1..a1f2128 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -214,9 +214,9 @@ function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) { function ActivityRefLabel({ viewBox, icon }) { if (!viewBox) return null - const { x, y } = viewBox + const { x, y, width = 0 } = viewBox return ( - + {icon} ) @@ -245,6 +245,14 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) const levelColor = bbLevelColor(end_level) const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null + // The X axis is categorical (band scale), so overlays must use values that + // exist in the data — snap activity start/end to the nearest sample. + const nearestT = (ms) => { + let best = null, bd = Infinity + for (const d of chartData) { const dd = Math.abs(d.t - ms); if (dd < bd) { bd = dd; best = d.t } } + return best + } + return (

Body Battery

@@ -272,9 +280,9 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) interval={Math.max(1, Math.floor(chartData.length / 6))} /> v} ticks={[0, 25, 50, 75, 100]} /> - format(new Date(ts), 'HH:mm')} - formatter={v => [`${Math.round(v)}`, 'Battery']} /> + formatter={v => [`${Math.round(v)}%`, 'Battery']} /> {chartData.map((d, i) => ( @@ -283,26 +291,20 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {(activities || []).map(a => { const start = new Date(a.start_time).getTime() const end = a.duration_s ? start + a.duration_s * 1000 : start + const x1 = nearestT(start), x2 = nearestT(end) + if (x1 == null || x2 == null) return null return ( } /> ) })} - {(activities || []).map(a => ( - } - /> - ))}
@@ -741,7 +743,7 @@ function SleepChart({ data, selectedDate, onDayClick }) { { @@ -759,10 +761,10 @@ function SleepChart({ data, selectedDate, onDayClick }) { )} + label={{ value: '8h', position: 'right', fill: '#22c55e', fontSize: 9 }} /> {avgSleep != null && ( + label={{ value: `avg ${avgSleep}h`, position: 'right', fill: '#a855f7', fontSize: 9 }} /> )} @@ -819,11 +821,11 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) { } const maxKg = Math.max(...withWeight.map(d => d.weight_kg)) - const minW = Math.min(...series.map(s => s.w)) + const minKg = Math.min(...withWeight.map(d => d.weight_kg)) const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent - const yMin = Math.max(0, Math.floor(minW - (imperial ? 6 : 3))) - const fmtVal = (v) => (imperial ? fmtStLb(v) : `${v.toFixed(1)} kg`) + const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight − 20 kg equivalent + const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`) return ( <> @@ -855,7 +857,7 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) { )} {goalU != null && ( + label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} /> )}