Files
MileVault/frontend/src/pages/RoutesPage.jsx
T
owain 45ff4c26aa
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 1m9s
Build and push images / build-worker (push) Successful in 1m8s
Build and push images / build-frontend (push) Successful in 49s
Implemented all 9 UI fixes across health charts and activity detail pages. Changes are ready to push to git for the Docker build to pick them up.
2026-06-07 19:57:25 +01:00

400 lines
19 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
function formatSegDist(m) {
if (m == null) return '--'
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
function SegmentsPanel({ routeId, sportType }) {
const qc = useQueryClient()
const { data: segments } = useQuery({
queryKey: ['segments', routeId],
queryFn: () => api.get(`/routes/${routeId}/segments`).then(r => r.data),
})
const { data: bests } = useQuery({
queryKey: ['segment-bests', routeId],
queryFn: () => api.get(`/routes/${routeId}/segment-bests`).then(r => r.data),
})
const deleteSeg = useMutation({
mutationFn: segId => api.delete(`/routes/${routeId}/segments/${segId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['segments', routeId] })
qc.invalidateQueries({ queryKey: ['segment-bests', routeId] })
},
})
if (!segments?.length) return null
const bestMap = Object.fromEntries((bests || []).map(b => [b.segment_id, b]))
const kmSplits = segments.filter(s => s.name.startsWith('km '))
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
: null
const renderGroup = (group, title) => {
if (!group.length) return null
return (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{title}</p>
{group.map(seg => {
const best = bestMap[seg.id]
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className="text-gray-600 text-xs">{formatSegDist(seg.end_distance_m - seg.start_distance_m)}</span>
{best?.best_s != null ? (
<span className="font-mono text-yellow-400 text-xs w-14 text-right">{formatDuration(best.best_s)}</span>
) : (
<span className="text-gray-700 text-xs w-14 text-right">--</span>
)}
<button
onClick={() => { if (confirm(`Delete "${seg.name}"?`)) deleteSeg.mutate(seg.id) }}
className="text-gray-700 hover:text-red-400 transition-colors text-xs ml-1"
title="Delete segment"
></button>
</div>
)
})}
</div>
)
}
return (
<div className="border-t border-gray-800 pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-400">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
{renderGroup(kmSplits, '1km Splits')}
{renderGroup(hillsTurns, 'Hills & Turns')}
{theoreticalBest != null && (
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
}
// Decode Google encoded polyline to [[lat,lng], ...]
function decodePolyline(encoded) {
if (!encoded) return []
const points = []
let idx = 0, lat = 0, lng = 0
while (idx < encoded.length) {
let shift = 0, result = 0, byte
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
lat += result & 1 ? ~(result >> 1) : result >> 1
shift = 0; result = 0
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
lng += result & 1 ? ~(result >> 1) : result >> 1
points.push([lat / 1e5, lng / 1e5])
}
return points
}
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)
const rangeL = maxLng - minLng || 1e-5
const rangeA = maxLat - minLat || 1e-5
const pad = 4
const w = 100, h = 60
const toX = lng => pad + ((lng - minLng) / rangeL) * (w - pad * 2)
const toY = lat => pad + ((maxLat - lat) / rangeA) * (h - pad * 2)
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={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)
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
const [merging, setMerging] = useState(false)
const [mergeTarget, setMergeTarget] = useState('')
const qc = useQueryClient()
const { data: routes } = useQuery({
queryKey: ['routes'],
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),
enabled: !!selected,
})
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
},
})
const mergeRoute = useMutation({
mutationFn: ({ into, from }) => api.post(`/routes/${into}/merge/${from}`).then(r => r.data),
onSuccess: updated => {
qc.invalidateQueries({ queryKey: ['routes'] })
qc.invalidateQueries({ queryKey: ['route-activities', updated.id] })
setMerging(false)
setMergeTarget('')
setSelected(updated)
},
})
const deleteRoute = useMutation({
mutationFn: id => api.delete(`/routes/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['routes'] })
setSelected(null)
},
})
const fastest = routeActivities?.[0]
const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? []
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
<p className="text-xs text-gray-500 mt-1">
Routes are auto-detected when you run the same path twice. You can also create them manually.
</p>
</div>
<button onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ New route
</button>
</div>
{/* Create route panel */}
{showCreate && (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Create named route</h3>
<p className="text-xs text-gray-500">
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
</p>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
<input value={newRoute.name} onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. Morning park loop" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
<select value={newRoute.activity_id} onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Select an activity</option>
{recentActivities?.map(a => (
<option key={a.id} value={a.id}>
{sportIcon(a.sport_type)} {a.name} {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Create
</button>
<button onClick={() => setShowCreate(false)}
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
)}
{/* 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 — 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" 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">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="mb-4 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
<button onClick={() => setMerging(false)}
className="text-gray-400 hover:text-white text-sm px-3 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Course record */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
</div>
</div>
)}
{/* Activity list */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All completions ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate && (
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
)}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
))}
</div>
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
</div>
</div>
)}
</div>
)
}