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 (

{title}

{group.map(seg => { const best = bestMap[seg.id] return (
{seg.name} {formatSegDist(seg.end_distance_m - seg.start_distance_m)} {best?.best_s != null ? ( {formatDuration(best.best_s)} ) : ( -- )}
) })}
) } return (

Segments

Manage →
{renderGroup(kmSplits, '1km Splits')} {renderGroup(hillsTurns, 'Hills & Turns')} {theoreticalBest != null && (
Theoretical best (1km splits only) {formatDuration(theoreticalBest)}
)}
) } // 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 (
no track
) 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 ( ) } 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 (

Named Routes

Routes are auto-detected when you run the same path twice. You can also create them manually.

{/* Create route panel */} {showCreate && (

Create named route

Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.

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" />
)} {/* Route tile grid */} {routes?.length === 0 && !showCreate ? (

🗺️

No named routes yet

Routes are created automatically when you repeat a run, or create one manually above.

) : (
{sortedRoutes.map(route => { const style = routeSportStyle(route.sport_type) const isSelected = selected?.id === route.id return ( ) })}
)} {/* Route detail — shown below the tile grid when a route is selected */} {selected && (

{selected.name}

{selected.sport_type && {selected.sport_type}} {formatDistance(selected.distance_m)} {selected.auto_detected && ( Auto-detected )}
{/* Merge panel */} {merging && (

Merge another route into this one

All activities from the selected route will be moved here, then the other route will be deleted.

{otherRoutes.length === 0 && (

No other {selected.sport_type} routes to merge with.

)}
)} {/* Course record */} {fastest && (

Course record 🏆

{formatDuration(fastest.duration_s)} {formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
)} {/* Activity list */}

All completions ({routeActivities?.length ?? 0})

{routeActivities?.map((act, i) => ( {i + 1} {formatDate(act.start_time)} {formatDuration(act.duration_s)} {formatPace(act.avg_speed_ms, selected.sport_type)} {act.avg_heart_rate && ( {Math.round(act.avg_heart_rate)} bpm )} {i === 0 && ( CR )} ))}
)}
) }