@@ -1,68 +1,40 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useState , useEffect } from 'react'
import { Link , useParams , useNavigate } 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 } from '../utils/format '
import RouteTileMap from '../components/ui/RouteTileMap '
import { formatDistance , formatDuration , formatDate , formatPace , sportColor } from '../utils/format'
import { useUnit } from '../hooks/useUnits'
// 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 / 1 e5 , lng / 1 e5 ] )
}
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 || 1 e - 5
const rangeA = maxLat - minLat || 1 e - 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' }
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 = [ '🥇' , '🥈' , '🥉' ]
const SORT _OPTIONS = [
{ value : 'recent' , label : 'Date last completed' } ,
{ value : 'distance' , label : 'Distance' } ,
{ value : 'completions' , label : 'Times completed' } ,
]
function sortRoutes ( routes , sortBy ) {
const arr = [ ... routes ]
if ( sortBy === 'distance' ) {
arr . sort ( ( a , b ) => ( b . distance _m || 0 ) - ( a . distance _m || 0 ) )
} else if ( sortBy === 'completions' ) {
arr . sort ( ( a , b ) => ( b . activity _count || 0 ) - ( a . activity _count || 0 ) )
} else { // recent — most recently completed first, then newest route
arr . sort ( ( a , b ) => {
const at = a . last _activity _at ? new Date ( a . last _activity _at ) : new Date ( a . created _at )
const bt = b . last _activity _at ? new Date ( b . last _activity _at ) : new Date ( b . created _at )
return bt - at
} )
}
return arr
}
function RouteDetail ( { selected , setSelected } ) {
const qc = useQueryClient ( )
const unit = useUnit ( )
const navigate = useNavigate ( )
const [ merging , setMerging ] = useState ( false )
const [ mergeTarget , setMergeTarget ] = useState ( '' )
const [ editingName , setEditingName ] = useState ( false )
@@ -103,6 +75,7 @@ function RouteDetail({ selected, setSelected }) {
onSuccess : ( ) => {
qc . invalidateQueries ( { queryKey : [ 'routes' ] } )
setSelected ( null )
navigate ( '/routes' )
} ,
} )
@@ -117,7 +90,7 @@ function RouteDetail({ selected, setSelected }) {
< div className = "w-full sm:w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800" >
{ selected . reference _polyline
? < ActivityMap polyline = { selected . reference _polyline } sportType = { selected . sport _type } colorMode = "solid" / >
: < RouteMap polyline = { selected . reference _polyline } className = "w-full h-full" sportType = { selected . sport _type } / > }
: < div className = "w-full h-full bg-gray-800 flex items-center justify-center text-gray-600 text-xs" > no track < / div > }
< / div >
< div className = "min-w-0" >
{ editingName ? (
@@ -235,9 +208,12 @@ function RouteDetail({ selected, setSelected }) {
export default function RoutesPage ( ) {
const unit = useUnit ( )
const { routeId } = useParams ( )
const navigate = useNavigate ( )
const [ selected , setSelected ] = useState ( null )
const [ showCreate , setShowCreate ] = useState ( false )
const [ newRoute , setNewRoute ] = useState ( { name : '' , activity _id : '' } )
const [ sortBy , setSortBy ] = useState ( 'recent' )
const qc = useQueryClient ( )
const { data : routes } = useQuery ( {
@@ -245,8 +221,17 @@ export default function RoutesPage() {
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 ) )
// Deep-link: when arriving at /routes/:routeId, pre-select that route.
useEffect ( ( ) => {
if ( routeId && routes ) {
const found = routes . find ( r => r . id === Number ( routeId ) )
if ( found ) setSelected ( found )
}
} , [ routeId , routes ] )
// Split into custom-named and auto-detected, sorted within each group.
const customRoutes = sortRoutes ( ( routes || [ ] ) . filter ( r => ! r . auto _detected ) , sortBy )
const autoRoutes = sortRoutes ( ( routes || [ ] ) . filter ( r => r . auto _detected ) , sortBy )
const { data : recentActivities } = useQuery ( {
queryKey : [ 'recent-activities-for-route' ] ,
@@ -261,22 +246,63 @@ export default function RoutesPage() {
setShowCreate ( false )
setNewRoute ( { name : '' , activity _id : '' } )
setSelected ( route )
navigate ( ` /routes/ ${ route . id } ` )
} ,
} )
const selectRoute = ( route , isSelected ) => {
if ( isSelected ) { setSelected ( null ) ; navigate ( '/routes' ) }
else { setSelected ( route ) ; navigate ( ` /routes/ ${ route . id } ` ) }
}
const renderGrid = ( list ) => (
< div className = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3" >
{ list . map ( route => {
const color = sportColor ( route . sport _type )
const isSelected = selected ? . id === route . id
return [
< button key = { route . id }
onClick = { ( ) => selectRoute ( route , isSelected ) }
style = { isSelected ? { borderColor : color , backgroundColor : color + '14' } : undefined }
className = { ` text-left rounded-xl border p-2 transition-all ${
isSelected ? '' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
} ` } >
< RouteTileMap polyline = { route . reference _polyline } sportType = { route . sport _type }
className = "w-full h-32 rounded-lg overflow-hidden" / >
< 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 , unit ) } < / span >
{ route . activity _count > 0 && (
< span className = "text-xs font-medium" style = { { color } } > { route . activity _count } × < / span >
) }
< / div >
< / button > ,
isSelected && < RouteDetail key = { ` detail- ${ route . id } ` } selected = { selected } setSelected = { setSelected } / > ,
]
} ) }
< / div >
)
return (
< div className = "p-4 md:p-6 space-y-6" >
< div className = "flex items-center justify-between" >
< div className = "flex flex-col sm:flex-row sm: items-center sm: justify-between gap-3 " >
< 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 className = "flex items-center gap-2" >
< label className = "text-xs text-gray-500" > Sort by < / label >
< select value = { sortBy } onChange = { e => setSortBy ( e . target . value ) }
className = "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" >
{ SORT _OPTIONS . map ( o => < option key = { o . value } value = { o . value } > { o . label } < / option > ) }
< / select >
< button onClick = { ( ) => setShowCreate ( true ) }
className = "bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors whitespace-nowrap" >
+ New route
< / button >
< / div >
< / div >
{ /* Create route panel */ }
@@ -320,7 +346,8 @@ export default function RoutesPage() {
< / div >
) }
{ /* Route tile grid — selected route's detail expands inline under its row */ }
{ /* Route tiles, grouped (custom above auto-detected) — the selected
route's detail expands inline under its row within each grid. */ }
{ routes ? . length === 0 && ! showCreate ? (
< div className = "text-center py-12 text-gray-600" >
< p className = "text-3xl mb-2" > 🗺 ️ < / p >
@@ -328,29 +355,19 @@ export default function RoutesPage() {
< 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 ) }
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 , unit ) } < / 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 > ,
isSelected && < RouteDetail key = { ` detail- ${ route . id } ` } selected = { selected } setSelected = { setSelected } / > ,
]
} ) }
< div className = "space-y-6 " >
{ customRoutes . length > 0 && (
< div className = "space-y-2" >
< h2 className = "text-sm font-semibold text-gray-300" > Custom routes < / h2 >
{ renderGrid ( customRoutes ) }
< / div >
) }
{ autoRoutes . length > 0 && (
< div className = "space-y-2" >
< h2 className = "text-sm font-semibold text-gray-300" > Auto - detected routes < / h2 >
{ renderGrid ( autoRoutes ) }
< / div >
) }
< / div >
) }
< / div >