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)} +
+ )}
)}