Fix pace sentinel, route map thumbnails, tiled segments, health/dashboard layout
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s

- Pace: FIT 0xFFFF sentinel (65.535 m/s) was stored as avg_speed_ms on every
  activity and lap; add _sanitize_speed() to parser falling back to dist/dur,
  plus a startup SQL migration that fixed 120 activities and 688 laps in-place
- Records: remove swimming from Distance PRs; Route Records rows are clickable
  (navigate to activity), View button removed, small SVG route map per row;
  Segment Records uses same tiled route-card layout as Segments page
- Segments: replace route dropdown with responsive tile grid showing SVG map
  thumbnails; selecting a tile reveals the segment management panel below
- RouteMiniMap: new pure-SVG component (no Leaflet) for route thumbnails,
  decodes polyline and normalises coords into a fixed viewBox
- Health: rename "Avg Heart Rate (day)" → "Heart Rate"; weight chart now
  filters to non-null rows and enables connectNulls + dots for sparse data
- Dashboard: 4-col layout at lg breakpoint so Body Battery sits between weekly
  chart and Health Today; Body Battery card gains a 24-hr sparkline from the
  values[] already present in the health summary response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:36:54 +01:00
parent 5f5551db27
commit 4a4cbdcc92
10 changed files with 267 additions and 95 deletions
+52 -27
View File
@@ -1,6 +1,6 @@
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api'
import StatCard from '../components/ui/StatCard'
@@ -19,10 +19,13 @@ function bbLevelColor(level) {
function MiniBodyBattery({ bb }) {
if (!bb?.end_level && !bb?.charged) return null
const { charged, drained, start_level, end_level } = bb
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 }))
: []
return (
<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 h-full">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
<Link to="/health" className="text-xs text-blue-400 hover:underline">View </Link>
@@ -41,6 +44,27 @@ function MiniBodyBattery({ bb }) {
{start_level != null && end_level != null && (
<p className="text-xs text-gray-500 mt-1">{start_level} {end_level} today</p>
)}
{sparkData.length >= 2 && (
<div className="mt-3">
<ResponsiveContainer width="100%" height={60}>
<AreaChart data={sparkData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="bbGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)}`, 'Battery']}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
)
}
@@ -145,36 +169,37 @@ export default function DashboardPage() {
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
<WeeklyChart activities={allActivities} />
</div>
<div className="flex flex-col gap-4">
<div className="lg:col-span-1">
<MiniBodyBattery bb={latest?.body_battery} />
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? (
<>
{[
['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) : '--'],
].map(([label, val]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span>
<span className="text-white">{val}</span>
</div>
))}
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard </Link>
</>
) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
)}
</div>
</div>
<div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? (
<>
{[
['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) : '--'],
].map(([label, val]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span>
<span className="text-white">{val}</span>
</div>
))}
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard </Link>
</>
) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
)}
</div>
</div>
+10 -5
View File
@@ -432,7 +432,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
// ── Trend Charts ────────────────────────────────────────────────────────────
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick }) {
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
@@ -465,7 +465,9 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
fill={`url(#grad-${dataKey})`}
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
connectNulls={connectNulls} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
)
@@ -657,9 +659,12 @@ export default function HealthPage() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
<MetricChart
data={metrics.filter(d => d.weight_kg != null)}
dataKey="weight_kg" color="#34d399"
formatter={v => `${v.toFixed(1)} kg`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
selectedDate={selDateForCharts} onDayClick={handleDayClick}
connectNulls showDots />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -697,7 +702,7 @@ export default function HealthPage() {
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
+57 -37
View File
@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { format } from 'date-fns'
import api from '../utils/api'
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
import RouteMiniMap from '../components/ui/RouteMiniMap'
const SPORTS = ['running', 'cycling', 'swimming']
const SPORTS = ['running', 'cycling']
const DISTANCE_ORDER = [
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
@@ -149,6 +150,7 @@ function DistancePRs() {
}
function RouteRecords() {
const navigate = useNavigate()
const { data: records, isLoading } = useQuery({
queryKey: ['route-records'],
queryFn: () => api.get('/records/routes').then(r => r.data),
@@ -168,38 +170,40 @@ function RouteRecords() {
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="text-left px-4 py-3 font-medium">Route</th>
<th className="text-right px-4 py-3 font-medium">Distance</th>
<th className="text-right px-4 py-3 font-medium">Best time</th>
<th className="text-right px-4 py-3 font-medium">Pace</th>
<th className="text-right px-4 py-3 font-medium">Date</th>
<th className="px-4 py-3" />
<th className="px-3 py-3" />
<th className="text-left px-3 py-3 font-medium">Route</th>
<th className="text-right px-3 py-3 font-medium">Distance</th>
<th className="text-right px-3 py-3 font-medium">Best time</th>
<th className="text-right px-3 py-3 font-medium">Pace</th>
<th className="text-right px-3 py-3 font-medium">Date</th>
</tr>
</thead>
<tbody>
{records.map(rec => (
<tr key={rec.route_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
<td className="px-4 py-3 font-medium text-white">
<tr
key={rec.route_id}
onClick={() => navigate(`/activities/${rec.activity_id}`)}
className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer"
>
<td className="px-3 py-2">
<RouteMiniMap polyline={rec.reference_polyline} sportType={rec.sport_type} width={72} height={50} />
</td>
<td className="px-3 py-3 font-medium text-white">
<span className="capitalize text-xs text-gray-500 mr-2">{rec.sport_type}</span>
{rec.route_name}
</td>
<td className="px-4 py-3 text-right text-gray-400 text-xs">
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{formatDistance(rec.distance_m)}
</td>
<td className="px-4 py-3 text-right font-mono text-yellow-400 font-semibold">
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
{formatDuration(rec.duration_s)}
</td>
<td className="px-4 py-3 text-right text-gray-400 text-xs">
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{formatPace(rec.avg_speed_ms, rec.sport_type)}
</td>
<td className="px-4 py-3 text-right text-gray-400 text-xs">
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{formatDate(rec.start_time)}
</td>
<td className="px-4 py-3 text-right">
<Link to={`/activities/${rec.activity_id}`} className="text-xs text-blue-400 hover:underline">
View
</Link>
</td>
</tr>
))}
</tbody>
@@ -226,33 +230,49 @@ function SegmentRecords() {
? bests.reduce((sum, b) => sum + b.best_s, 0)
: null
if (!routes?.length) return (
<p className="text-sm text-gray-600">
No named routes yet.{' '}
<Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link>
</p>
)
return (
<div className="space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<label className="block text-xs text-gray-500 mb-2">Select a route</label>
{!routes?.length ? (
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
) : (
<select
value={selectedRouteId ?? ''}
onChange={e => setSelectedRouteId(e.target.value ? parseInt(e.target.value) : null)}
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
{/* Route tile grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<option value=""> choose a route </option>
{routes.map(r => (
<option key={r.id} value={r.id}>
{r.name}{r.distance_m ? ` (${(r.distance_m / 1000).toFixed(1)} km)` : ''}
</option>
))}
</select>
)}
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
</button>
))}
</div>
{selectedRouteId && (
isLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : !bests?.length ? (
<p className="text-gray-600 text-sm">No segments for this route. <Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link></p>
<p className="text-gray-600 text-sm">
No segments for this route.{' '}
<Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link>
</p>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
+31 -19
View File
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import api from '../utils/api'
import { formatDuration, formatDistance } from '../utils/format'
import RouteMiniMap from '../components/ui/RouteMiniMap'
function formatSegmentDist(m) {
if (m == null) return '--'
@@ -218,26 +219,37 @@ export default function SegmentsPage() {
<h1 className="text-2xl font-bold text-white">Segments</h1>
</div>
{/* Route selector */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<label className="block text-xs text-gray-500 mb-2">Select a route</label>
{!routes?.length ? (
{/* Route tile grid */}
{!routes?.length ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
) : (
<select
value={selectedRouteId ?? ''}
onChange={e => setSelectedRouteId(e.target.value ? parseInt(e.target.value) : null)}
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value=""> choose a route </option>
{routes.map(r => (
<option key={r.id} value={r.id}>
{r.name}{r.distance_m ? ` (${(r.distance_m / 1000).toFixed(1)} km)` : ''}
</option>
))}
</select>
)}
</div>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
</button>
))}
</div>
)}
{selectedRoute && (
<div className="space-y-4">