diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py
index 227bd67..7d9650d 100644
--- a/backend/app/api/routes.py
+++ b/backend/app/api/routes.py
@@ -35,6 +35,7 @@ class RouteOut(BaseModel):
auto_detected: Optional[bool]
created_at: datetime
activity_count: int = 0
+ last_activity_at: Optional[datetime] = None
class Config:
from_attributes = True
@@ -45,24 +46,33 @@ async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- # Fetch routes with activity counts in one query
- count_subq = (
- select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
+ # Fetch routes with activity counts and last-completed time in one query
+ agg_subq = (
+ select(
+ Activity.named_route_id,
+ func.count(Activity.id).label("cnt"),
+ func.max(Activity.start_time).label("last_at"),
+ )
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute(
- select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
- .outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
+ select(
+ NamedRoute,
+ func.coalesce(agg_subq.c.cnt, 0).label("activity_count"),
+ agg_subq.c.last_at.label("last_activity_at"),
+ )
+ .outerjoin(agg_subq, NamedRoute.id == agg_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
rows = result.all()
out = []
- for route, cnt in rows:
+ for route, cnt, last_at in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
+ d["last_activity_at"] = last_at
out.append(RouteOut(**d))
return out
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0b58b06..dd56a5c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -37,6 +37,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/ui/RouteTileMap.jsx b/frontend/src/components/ui/RouteTileMap.jsx
new file mode 100644
index 0000000..8baa15f
--- /dev/null
+++ b/frontend/src/components/ui/RouteTileMap.jsx
@@ -0,0 +1,61 @@
+import { useEffect, useRef } from 'react'
+import L from 'leaflet'
+import { sportColor } from '../../utils/format'
+
+// Voyager raster tiles β same street style used on the activity map.
+const TILE_URL = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
+
+function decodePolyline(encoded) {
+ if (!encoded) return []
+ const coords = []
+ let index = 0, lat = 0, lng = 0
+ while (index < encoded.length) {
+ let b, shift = 0, result = 0
+ do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
+ lat += (result & 1) ? ~(result >> 1) : result >> 1
+ shift = 0; result = 0
+ do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
+ lng += (result & 1) ? ~(result >> 1) : result >> 1
+ coords.push([lat / 1e5, lng / 1e5])
+ }
+ return coords
+}
+
+// A small, non-interactive map showing the route polyline over real map tiles.
+// Wrapped in pointer-events:none so clicks fall through to the parent tile button.
+export default function RouteTileMap({ polyline, sportType, className = '' }) {
+ const elRef = useRef(null)
+ const mapRef = useRef(null)
+
+ useEffect(() => {
+ if (!elRef.current || mapRef.current) return
+ const map = L.map(elRef.current, {
+ zoomControl: false, attributionControl: false, dragging: false,
+ scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false,
+ keyboard: false, touchZoom: false, tap: false, preferCanvas: true,
+ })
+ mapRef.current = map
+ L.tileLayer(TILE_URL, { maxZoom: 19 }).addTo(map)
+ return () => { map.remove(); mapRef.current = null }
+ }, [])
+
+ useEffect(() => {
+ const map = mapRef.current
+ if (!map) return
+ const coords = decodePolyline(polyline)
+ map.eachLayer(layer => { if (layer instanceof L.Polyline) map.removeLayer(layer) })
+ if (coords.length >= 2) {
+ L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.95 }).addTo(map)
+ map.invalidateSize()
+ map.fitBounds(L.latLngBounds(coords), { padding: [12, 12] })
+ } else {
+ map.setView([0, 0], 1)
+ }
+ }, [polyline, sportType])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx
index 0f74615..9e3eb79 100644
--- a/frontend/src/pages/ActivityDetailPage.jsx
+++ b/frontend/src/pages/ActivityDetailPage.jsx
@@ -211,7 +211,7 @@ export default function ActivityDetailPage() {
{/* Named route link / create-route control */}
{activity.named_route_id ? (
- π {activity.named_route_name}
+ π {activity.named_route_name}
) : activity.polyline && activity.distance_m > 0 ? (
routeCreate ? (
diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx
index 9854c08..fe0bc8a 100644
--- a/frontend/src/pages/RoutesPage.jsx
+++ b/frontend/src/pages/RoutesPage.jsx
@@ -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 / 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 = ['π₯', 'π₯', 'π₯']
+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 }) {
{selected.reference_polyline
?
- :
}
+ :
no track
}
{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) => (
+
+ {list.map(route => {
+ const color = sportColor(route.sport_type)
+ const isSelected = selected?.id === route.id
+ return [
+
,
+ isSelected &&
,
+ ]
+ })}
+
+ )
+
return (
-
+
Named Routes
Routes are auto-detected when you run the same path twice. You can also create them manually.
-
+
+
+
+
+
{/* Create route panel */}
@@ -320,7 +346,8 @@ export default function RoutesPage() {
)}
- {/* 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 ? (
πΊοΈ
@@ -328,29 +355,19 @@ export default function RoutesPage() {
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 &&
,
- ]
- })}
+
+ {customRoutes.length > 0 && (
+
+
Custom routes
+ {renderGrid(customRoutes)}
+
+ )}
+ {autoRoutes.length > 0 && (
+
+
Auto-detected routes
+ {renderGrid(autoRoutes)}
+
+ )}
)}