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' && (
+
+ )}
{/* 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 }} />
)}