Fix pace sentinel, route map thumbnails, tiled segments, health/dashboard layout
- 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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user