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.
)}
{/* 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
)}
→
))}
)}
)
}