feat: routes page — deep-link to specific route, sport-colour tiles over real map, sort filter (distance/completions/last-completed), group custom vs auto-detected, taller map tiles
This commit is contained in:
@@ -35,6 +35,7 @@ class RouteOut(BaseModel):
|
|||||||
auto_detected: Optional[bool]
|
auto_detected: Optional[bool]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
activity_count: int = 0
|
activity_count: int = 0
|
||||||
|
last_activity_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -45,24 +46,33 @@ async def list_routes(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
# Fetch routes with activity counts in one query
|
# Fetch routes with activity counts and last-completed time in one query
|
||||||
count_subq = (
|
agg_subq = (
|
||||||
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
|
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))
|
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
|
||||||
.group_by(Activity.named_route_id)
|
.group_by(Activity.named_route_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
|
select(
|
||||||
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
|
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)
|
.where(NamedRoute.user_id == current_user.id)
|
||||||
.order_by(desc(NamedRoute.created_at))
|
.order_by(desc(NamedRoute.created_at))
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
out = []
|
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 = {c.name: getattr(route, c.name) for c in route.__table__.columns}
|
||||||
d["activity_count"] = cnt
|
d["activity_count"] = cnt
|
||||||
|
d["last_activity_at"] = last_at
|
||||||
out.append(RouteOut(**d))
|
out.append(RouteOut(**d))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default function App() {
|
|||||||
<Route path="summary" element={<SummaryPage />} />
|
<Route path="summary" element={<SummaryPage />} />
|
||||||
<Route path="health" element={<HealthPage />} />
|
<Route path="health" element={<HealthPage />} />
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
|
<Route path="routes/:routeId" element={<RoutesPage />} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
<Route path="upload" element={<UploadPage />} />
|
<Route path="upload" element={<UploadPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={className} style={{ pointerEvents: 'none', background: '#1a1a2e' }}>
|
||||||
|
<div ref={elRef} style={{ height: '100%', width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -211,7 +211,7 @@ export default function ActivityDetailPage() {
|
|||||||
{/* Named route link / create-route control */}
|
{/* Named route link / create-route control */}
|
||||||
{activity.named_route_id ? (
|
{activity.named_route_id ? (
|
||||||
<p className="text-sm text-blue-400 mt-1">
|
<p className="text-sm text-blue-400 mt-1">
|
||||||
📍 <Link to="/routes" className="hover:underline">{activity.named_route_name}</Link>
|
📍 <Link to={`/routes/${activity.named_route_id}`} className="hover:underline">{activity.named_route_name}</Link>
|
||||||
</p>
|
</p>
|
||||||
) : activity.polyline && activity.distance_m > 0 ? (
|
) : activity.polyline && activity.distance_m > 0 ? (
|
||||||
routeCreate ? (
|
routeCreate ? (
|
||||||
|
|||||||
@@ -1,68 +1,40 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
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'
|
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 (
|
|
||||||
<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 || 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 (
|
|
||||||
<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 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 }) {
|
function RouteDetail({ selected, setSelected }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const unit = useUnit()
|
const unit = useUnit()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [merging, setMerging] = useState(false)
|
const [merging, setMerging] = useState(false)
|
||||||
const [mergeTarget, setMergeTarget] = useState('')
|
const [mergeTarget, setMergeTarget] = useState('')
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
@@ -103,6 +75,7 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
setSelected(null)
|
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">
|
<div className="w-full sm:w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
|
||||||
{selected.reference_polyline
|
{selected.reference_polyline
|
||||||
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
|
? <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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
@@ -235,9 +208,12 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
|
|
||||||
export default function RoutesPage() {
|
export default function RoutesPage() {
|
||||||
const unit = useUnit()
|
const unit = useUnit()
|
||||||
|
const { routeId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||||
|
const [sortBy, setSortBy] = useState('recent')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: routes } = useQuery({
|
const { data: routes } = useQuery({
|
||||||
@@ -245,8 +221,17 @@ export default function RoutesPage() {
|
|||||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by most completions first
|
// Deep-link: when arriving at /routes/:routeId, pre-select that route.
|
||||||
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
|
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({
|
const { data: recentActivities } = useQuery({
|
||||||
queryKey: ['recent-activities-for-route'],
|
queryKey: ['recent-activities-for-route'],
|
||||||
@@ -261,22 +246,63 @@ export default function RoutesPage() {
|
|||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setNewRoute({ name: '', activity_id: '' })
|
setNewRoute({ name: '', activity_id: '' })
|
||||||
setSelected(route)
|
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 (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-6">
|
<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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<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.
|
Routes are auto-detected when you run the same path twice. You can also create them manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowCreate(true)}
|
<div className="flex items-center gap-2">
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
<label className="text-xs text-gray-500">Sort by</label>
|
||||||
+ New route
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Create route panel */}
|
{/* Create route panel */}
|
||||||
@@ -320,7 +346,8 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</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 ? (
|
{routes?.length === 0 && !showCreate ? (
|
||||||
<div className="text-center py-12 text-gray-600">
|
<div className="text-center py-12 text-gray-600">
|
||||||
<p className="text-3xl mb-2">🗺️</p>
|
<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>
|
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
<div className="space-y-6">
|
||||||
{sortedRoutes.map(route => {
|
{customRoutes.length > 0 && (
|
||||||
const style = routeSportStyle(route.sport_type)
|
<div className="space-y-2">
|
||||||
const isSelected = selected?.id === route.id
|
<h2 className="text-sm font-semibold text-gray-300">Custom routes</h2>
|
||||||
return [
|
{renderGrid(customRoutes)}
|
||||||
<button key={route.id}
|
</div>
|
||||||
onClick={() => setSelected(isSelected ? null : route)}
|
)}
|
||||||
className={`text-left rounded-xl border p-2 transition-all ${
|
{autoRoutes.length > 0 && (
|
||||||
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
|
<div className="space-y-2">
|
||||||
}`}>
|
<h2 className="text-sm font-semibold text-gray-300">Auto-detected routes</h2>
|
||||||
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
{renderGrid(autoRoutes)}
|
||||||
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user