Health hypnogram, routes tiles, BB bar chart, segment delta
- Sleep: store per-epoch stage timestamps in new sleep_stages JSON column; DailySnapshot now renders a proper 4-lane hypnogram (Awake/REM/Light/Deep) instead of the old proportional flat bar - Body battery: replace grey background bars + white line with per-minute bars coloured by inferred type (sleep=indigo, rest=teal, active=orange, stable=grey) derived from sleep window + battery direction; Y-axis fixed 0-100 - Routes: convert sidebar list to tile grid sorted by most completions; tiles colour-bordered by sport type (blue=running, orange=cycling); completion count shown on each tile; detail panel displays below the grid when a tile is clicked - Segments on activity detail: add column headers (This run / Best / Δ) and show signed time delta vs best, green when faster, red when slower Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -104,13 +104,15 @@ function decodePolyline(encoded) {
|
||||
return points
|
||||
}
|
||||
|
||||
function RouteMap({ polyline, className = '' }) {
|
||||
function RouteMap({ polyline, className = '', sportType = '' }) {
|
||||
const pts = decodePolyline(polyline)
|
||||
if (pts.length < 2) return (
|
||||
<div className={`bg-gray-800 rounded flex items-center justify-center text-gray-600 text-xs ${className}`}>
|
||||
no track
|
||||
</div>
|
||||
)
|
||||
const t = (sportType || '').toLowerCase()
|
||||
const stroke = (t.includes('cycl') || t.includes('bike') || t.includes('ride')) ? '#f97316' : '#3b82f6'
|
||||
const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
@@ -123,11 +125,20 @@ function RouteMap({ polyline, className = '' }) {
|
||||
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className={`bg-gray-800 rounded ${className}`} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d={d} fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={d} fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function routeSportStyle(sportType) {
|
||||
const t = (sportType || '').toLowerCase()
|
||||
if (t.includes('cycl') || t.includes('bike') || t.includes('ride'))
|
||||
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400', color: '#f97316' }
|
||||
if (t.includes('run') || t.includes('jog') || t.includes('walk'))
|
||||
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400', color: '#3b82f6' }
|
||||
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400', color: '#6b7280' }
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -141,6 +152,9 @@ export default function RoutesPage() {
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
// Sort by most completions first
|
||||
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
|
||||
|
||||
const { data: routeActivities } = useQuery({
|
||||
queryKey: ['route-activities', selected?.id],
|
||||
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||
@@ -241,47 +255,50 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Route list */}
|
||||
<div className="space-y-2">
|
||||
{routes?.length === 0 && !showCreate && (
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-3xl mb-2">🗺️</p>
|
||||
<p className="text-sm">No named routes yet</p>
|
||||
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||
</div>
|
||||
)}
|
||||
{routes?.map(route => (
|
||||
<button key={route.id} onClick={() => { setSelected(route); setMerging(false) }}
|
||||
className={`w-full text-left p-3 rounded-xl border transition-all ${
|
||||
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||
}`}>
|
||||
<div className="flex gap-3 items-start">
|
||||
<RouteMap polyline={route.reference_polyline} className="w-20 h-12 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<p className="font-medium text-white text-sm truncate">{route.name}</p>
|
||||
{route.auto_detected && (
|
||||
<span className="text-xs bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded-full flex-shrink-0">auto</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-0.5 text-xs text-gray-500">
|
||||
<span>{formatDistance(route.distance_m)}</span>
|
||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{/* Route tile grid */}
|
||||
{routes?.length === 0 && !showCreate ? (
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-3xl mb-2">🗺️</p>
|
||||
<p className="text-sm">No named routes yet</p>
|
||||
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{sortedRoutes.map(route => {
|
||||
const style = routeSportStyle(route.sport_type)
|
||||
const isSelected = selected?.id === route.id
|
||||
return (
|
||||
<button key={route.id}
|
||||
onClick={() => { setSelected(isSelected ? null : route); setMerging(false) }}
|
||||
className={`text-left rounded-xl border p-2 transition-all ${
|
||||
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
|
||||
}`}>
|
||||
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
||||
<div className="flex items-center justify-between mt-0.5 gap-1">
|
||||
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
|
||||
{route.activity_count > 0 && (
|
||||
<span className={`text-xs font-medium ${style.accent}`}>
|
||||
{route.activity_count}×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{route.auto_detected && (
|
||||
<span className="text-xs text-gray-600">auto</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route detail */}
|
||||
{selected && (
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
{/* Route detail — shown below the tile grid when a route is selected */}
|
||||
{selected && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex gap-4 items-start">
|
||||
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" />
|
||||
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||
@@ -351,7 +368,7 @@ export default function RoutesPage() {
|
||||
|
||||
{/* Activity list */}
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||
All runs ({routeActivities?.length ?? 0})
|
||||
All completions ({routeActivities?.length ?? 0})
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{routeActivities?.map((act, i) => (
|
||||
@@ -373,10 +390,9 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
|
||||
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user