Fix Garmin stats sync, add route merge/map/links, fix PR constraint
Garmin health: fix display_name=None when using stored OAuth tokens.
authenticate_garmin() now calls login(tokenstore=...) instead of
garth.loads() directly, so display_name is populated and get_user_summary
works. Also add avg_hr_day / max_hr_day from stats response.
Routes: add merge endpoint (POST /{id}/merge/{source}), delete endpoint.
Routes page: polyline SVG mini-map on each route card, merge UI with
confirmation, activity rows are now Links to the activity detail page.
Personal records: replace all-columns unique constraint with a partial
index (unique on current records only) to stop UniqueViolation crashes
when parallel workers deactivate the same PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,61 @@ async def route_activities(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
|
||||||
|
async def merge_routes(
|
||||||
|
route_id: int,
|
||||||
|
source_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Move all activities from source route into route_id, then delete source route."""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
target = (await db.execute(
|
||||||
|
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
source = (await db.execute(
|
||||||
|
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not target or not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
|
if route_id == source_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(Activity)
|
||||||
|
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
|
||||||
|
.values(named_route_id=route_id)
|
||||||
|
)
|
||||||
|
await db.delete(source)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{route_id}")
|
||||||
|
async def delete_route(
|
||||||
|
route_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from sqlalchemy import update as sa_update
|
||||||
|
route = (await db.execute(
|
||||||
|
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not route:
|
||||||
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
|
# Unlink activities before deleting
|
||||||
|
await db.execute(
|
||||||
|
sa_update(Activity)
|
||||||
|
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||||
|
.values(named_route_id=None)
|
||||||
|
)
|
||||||
|
await db.delete(route)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{route_id}/assign-activity")
|
@router.post("/{route_id}/assign-activity")
|
||||||
async def assign_activity_to_route(
|
async def assign_activity_to_route(
|
||||||
route_id: int,
|
route_id: int,
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Column migration skipped: {e}")
|
print(f"Column migration skipped: {e}")
|
||||||
|
|
||||||
|
# Replace the all-columns unique constraint on personal_records with a partial
|
||||||
|
# index (only current records must be unique per user/sport/distance).
|
||||||
|
# The old constraint also covered is_current_record=False rows, causing
|
||||||
|
# UniqueViolation crashes when multiple workers deactivate the same PR.
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE personal_records "
|
||||||
|
"DROP CONSTRAINT IF EXISTS uq_pr_current"
|
||||||
|
))
|
||||||
|
await conn.execute(text(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
|
||||||
|
"ON personal_records (user_id, sport_type, distance_m) "
|
||||||
|
"WHERE is_current_record = true"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"PR constraint migration skipped: {e}")
|
||||||
|
|
||||||
# Seed admin user (only if password is configured)
|
# Seed admin user (only if password is configured)
|
||||||
if not settings.admin_password:
|
if not settings.admin_password:
|
||||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||||
|
|||||||
@@ -46,15 +46,17 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str
|
|||||||
"""
|
"""
|
||||||
import garminconnect
|
import garminconnect
|
||||||
|
|
||||||
# Try stored OAuth token first (garth auto-refreshes access token on use)
|
# Try stored OAuth token first.
|
||||||
|
# Must call login(tokenstore=...) rather than garth.loads() directly so that
|
||||||
|
# garmin.display_name is populated — it's required by get_user_summary() and
|
||||||
|
# several other endpoints. Without it every stats call silently returns None.
|
||||||
if token_store:
|
if token_store:
|
||||||
try:
|
try:
|
||||||
garmin = garminconnect.Garmin(
|
garmin = garminconnect.Garmin(
|
||||||
email=email, password=decrypt_password(password_enc)
|
email=email, password=decrypt_password(password_enc)
|
||||||
)
|
)
|
||||||
garmin.garth.loads(token_store)
|
garmin.login(tokenstore=token_store)
|
||||||
garmin.get_full_name() # lightweight request; triggers refresh if needed
|
return garmin, None
|
||||||
return garmin, None # tokens still valid
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.info("Garmin token invalid (%s), re-authenticating", exc)
|
logger.info("Garmin token invalid (%s), re-authenticating", exc)
|
||||||
|
|
||||||
@@ -234,6 +236,8 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
|||||||
|
|
||||||
if stats:
|
if stats:
|
||||||
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
||||||
|
_set(row, "avg_hr_day", stats.get("averageHeartRate"))
|
||||||
|
_set(row, "max_hr_day", stats.get("maxHeartRate"))
|
||||||
_set(row, "steps", stats.get("totalSteps"))
|
_set(row, "steps", stats.get("totalSteps"))
|
||||||
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
||||||
_set(row, "avg_stress", stats.get("averageStressLevel"))
|
_set(row, "avg_stress", stats.get("averageStressLevel"))
|
||||||
|
|||||||
@@ -1,12 +1,56 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Link } 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 { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||||
|
|
||||||
|
// 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 = '' }) {
|
||||||
|
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 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="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function RoutesPage() {
|
export default function RoutesPage() {
|
||||||
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 [merging, setMerging] = useState(false)
|
||||||
|
const [mergeTarget, setMergeTarget] = useState('')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: routes } = useQuery({
|
const { data: routes } = useQuery({
|
||||||
@@ -27,8 +71,8 @@ export default function RoutesPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createRoute = useMutation({
|
const createRoute = useMutation({
|
||||||
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
|
mutationFn: data => api.post('/routes/', data).then(r => r.data),
|
||||||
onSuccess: (route) => {
|
onSuccess: route => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setNewRoute({ name: '', activity_id: '' })
|
setNewRoute({ name: '', activity_id: '' })
|
||||||
@@ -36,7 +80,27 @@ export default function RoutesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 fastest = routeActivities?.[0]
|
||||||
|
const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -53,7 +117,7 @@ export default function RoutesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create route */}
|
{/* Create route panel */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
<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>
|
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||||||
@@ -63,21 +127,14 @@ export default function RoutesPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||||||
<input value={newRoute.name}
|
<input value={newRoute.name} onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||||
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"
|
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" />
|
placeholder="e.g. Morning park loop" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
||||||
{recentActivities?.length === 0 ? (
|
<select value={newRoute.activity_id} onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||||
<p className="text-xs text-gray-600 py-2">No recent activities found.</p>
|
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">
|
||||||
) : (
|
|
||||||
<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>
|
<option value="">Select an activity…</option>
|
||||||
{recentActivities?.map(a => (
|
{recentActivities?.map(a => (
|
||||||
<option key={a.id} value={a.id}>
|
<option key={a.id} value={a.id}>
|
||||||
@@ -85,12 +142,10 @@ export default function RoutesPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||||
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
|
||||||
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
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">
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
Create
|
Create
|
||||||
@@ -114,20 +169,24 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{routes?.map(route => (
|
{routes?.map(route => (
|
||||||
<button key={route.id} onClick={() => setSelected(route)}
|
<button key={route.id} onClick={() => { setSelected(route); setMerging(false) }}
|
||||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
className={`w-full text-left p-3 rounded-xl border transition-all ${
|
||||||
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex gap-3 items-start">
|
||||||
<p className="font-medium text-white">{route.name}</p>
|
<RouteMap polyline={route.reference_polyline} className="w-20 h-12 flex-shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-1">
|
||||||
|
<p className="font-medium text-white text-sm truncate">{route.name}</p>
|
||||||
{route.auto_detected && (
|
{route.auto_detected && (
|
||||||
<span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
|
<span className="text-xs bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded-full flex-shrink-0">auto</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
<div className="flex gap-2 mt-0.5 text-xs text-gray-500">
|
||||||
<span>{formatDistance(route.distance_m)}</span>
|
<span>{formatDistance(route.distance_m)}</span>
|
||||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
||||||
<span>{formatDate(route.created_at)}</span>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -137,15 +196,64 @@ export default function RoutesPage() {
|
|||||||
{selected && (
|
{selected && (
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<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" />
|
||||||
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
<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 && (
|
{selected.auto_detected && (
|
||||||
<span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
|
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
|
||||||
Auto-detected
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{fastest && (
|
||||||
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
<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>
|
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
|
||||||
@@ -158,13 +266,15 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Activity list */}
|
||||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||||
All runs ({routeActivities?.length ?? 0})
|
All runs ({routeActivities?.length ?? 0})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
{routeActivities?.map((act, i) => (
|
{routeActivities?.map((act, i) => (
|
||||||
<div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
|
<Link key={act.id} to={`/activities/${act.id}`}
|
||||||
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
|
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="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="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>
|
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
||||||
@@ -174,7 +284,8 @@ export default function RoutesPage() {
|
|||||||
{i === 0 && (
|
{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-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors">→</span>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user