400 lines
19 KiB
React
400 lines
19 KiB
React
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 (
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{title}</p>
|
||
{group.map(seg => {
|
||
const best = bestMap[seg.id]
|
||
return (
|
||
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
|
||
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
|
||
<span className="text-gray-600 text-xs">{formatSegDist(seg.end_distance_m - seg.start_distance_m)}</span>
|
||
{best?.best_s != null ? (
|
||
<span className="font-mono text-yellow-400 text-xs w-14 text-right">{formatDuration(best.best_s)}</span>
|
||
) : (
|
||
<span className="text-gray-700 text-xs w-14 text-right">--</span>
|
||
)}
|
||
<button
|
||
onClick={() => { if (confirm(`Delete "${seg.name}"?`)) deleteSeg.mutate(seg.id) }}
|
||
className="text-gray-700 hover:text-red-400 transition-colors text-xs ml-1"
|
||
title="Delete segment"
|
||
>✕</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="border-t border-gray-800 pt-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-gray-400">Segments</h3>
|
||
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage →</Link>
|
||
</div>
|
||
{renderGroup(kmSplits, '1km Splits')}
|
||
{renderGroup(hillsTurns, 'Hills & Turns')}
|
||
{theoreticalBest != null && (
|
||
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
|
||
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
|
||
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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', 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 (
|
||
<div className="p-6 space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<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>
|
||
|
||
{/* Create route panel */}
|
||
{showCreate && (
|
||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||
<p className="text-xs text-gray-500">
|
||
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
||
</p>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||
<input value={newRoute.name} onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||
className="w-full 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"
|
||
placeholder="e.g. Morning park loop" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
||
<select value={newRoute.activity_id} onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||
className="w-full 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">
|
||
<option value="">Select an activity…</option>
|
||
{recentActivities?.map(a => (
|
||
<option key={a.id} value={a.id}>
|
||
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||
Create
|
||
</button>
|
||
<button onClick={() => setShowCreate(false)}
|
||
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Route tile grid */}
|
||
{routes?.length === 0 && !showCreate ? (
|
||
<div className="text-center py-12 text-gray-600">
|
||
<p className="text-3xl mb-2">🗺️</p>
|
||
<p className="text-sm">No named routes yet</p>
|
||
<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); setMerging(false) }}
|
||
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)}</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>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Route detail — shown below the tile grid when a route is selected */}
|
||
{selected && (
|
||
<div className="space-y-4">
|
||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||
<div className="flex items-start justify-between mb-4">
|
||
<div className="flex gap-4 items-start">
|
||
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
|
||
<span>{formatDistance(selected.distance_m)}</span>
|
||
{selected.auto_detected && (
|
||
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 flex-shrink-0">
|
||
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
|
||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
|
||
Merge
|
||
</button>
|
||
<button
|
||
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
|
||
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Merge panel */}
|
||
{merging && (
|
||
<div className="mb-4 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
|
||
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
|
||
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
|
||
<div className="flex gap-2">
|
||
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
|
||
className="flex-1 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-yellow-500">
|
||
<option value="">Select route to merge in…</option>
|
||
{otherRoutes.map(r => (
|
||
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
disabled={!mergeTarget || mergeRoute.isPending}
|
||
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
|
||
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||
Merge
|
||
</button>
|
||
<button onClick={() => setMerging(false)}
|
||
className="text-gray-400 hover:text-white text-sm px-3 py-2 rounded-lg transition-colors">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
{otherRoutes.length === 0 && (
|
||
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Course record */}
|
||
{fastest && (
|
||
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
||
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
|
||
<div className="flex items-center gap-4">
|
||
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
|
||
<span className="text-sm text-gray-400">
|
||
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Activity list */}
|
||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||
All completions ({routeActivities?.length ?? 0})
|
||
</h3>
|
||
<div className="space-y-1">
|
||
{routeActivities?.map((act, i) => (
|
||
<Link key={act.id} to={`/activities/${act.id}`}
|
||
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
|
||
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
|
||
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
||
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
||
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
||
{act.avg_heart_rate && (
|
||
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
|
||
)}
|
||
{i === 0 && (
|
||
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
|
||
)}
|
||
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors">→</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
|
||
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|