import { useState } from 'react' import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import ActivityMap from '../components/activity/ActivityMap' 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 = '', 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' } 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' } return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' } } const MEDALS = ['🥇', '🥈', '🥉'] function RouteDetail({ selected, setSelected }) { const qc = useQueryClient() const [merging, setMerging] = useState(false) const [mergeTarget, setMergeTarget] = useState('') const [editingName, setEditingName] = useState(false) const [nameInput, setNameInput] = useState(selected.name) 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), }) const renameRoute = useMutation({ mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data), onSuccess: updated => { qc.invalidateQueries({ queryKey: ['routes'] }) setSelected(updated) setEditingName(false) }, }) 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 crTime = fastest?.duration_s const otherRoutes = (routes || []).filter(r => r.id !== selected.id && r.sport_type === selected.sport_type) return (
{selected.reference_polyline ? : }
{editingName ? (
setNameInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && nameInput.trim()) renameRoute.mutate(nameInput.trim()) }} autoFocus className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
) : (

{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.

)}
)} {/* Podium */} {routeActivities?.length > 0 && (
{routeActivities.slice(0, 3).map((act, i) => (

{MEDALS[i]}

{formatDuration(act.duration_s)}

{formatDate(act.start_time)}

{i > 0 && crTime != null && (

+{formatDuration(act.duration_s - crTime)}

)} ))}
)} {/* All completions */}

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

{routeActivities?.map((act, i) => { const delta = crTime != null ? act.duration_s - crTime : null return ( {i + 1} {formatDate(act.start_time)} {formatDuration(act.duration_s)} {i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''} {formatPace(act.avg_speed_ms, selected.sport_type)} {act.avg_heart_rate ? {Math.round(act.avg_heart_rate)} bpm : } ) })}
) } export default function RoutesPage() { const [selected, setSelected] = useState(null) const [showCreate, setShowCreate] = useState(false) const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) 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: 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) }, }) 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 — selected route's detail expands inline under its row */} {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 [ , isSelected && , ] })}
)}
) }