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' // 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 = '' }) { const pts = decodePolyline(polyline) if (pts.length < 2) return (
no track
) 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 ( ) } 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), }) 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 list */}
{routes?.length === 0 && !showCreate && (

🗺️

No named routes yet

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

)} {routes?.map(route => ( ))}
{/* Route detail */} {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 runs ({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 )} ))}
)}
) }