Add segments, YTD stats, route matching fixes, body battery layout, pace fix
- Segments page: new /segments route with auto-generate (1km splits, turn detection, hill detection), manual segment creation, per-segment performance times across matched activities; fixed auth on existing segment endpoints - YTD distance: new /activities/stats/ytd endpoint; Dashboard replaces 'Total distance' with 'Running this year' + 'Cycling this year'; Activities page shows YTD stats row - Weekly chart click: clicking a Dashboard bar navigates to Activities filtered to that week; Activities reads from/to query params with dismissable chip - Route matching: add ±2.5% distance gate + 3% relative DTW threshold (was flat 80m); tighten candidate pre-filter from 80/120% to 95/105% - Body battery layout: HR chart and body battery now side-by-side at same height on large screens instead of stacked full-width - Pace display fix: MetricTimeline clamps GPS speed outliers before computing Y-axis domain; tick formatter guards against v<=0 or v>25 m/s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||
@@ -10,24 +11,38 @@ import {
|
||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||
|
||||
export default function ActivitiesPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [sport, setSport] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fromParam = searchParams.get('from')
|
||||
const toParam = searchParams.get('to')
|
||||
|
||||
const { data: activities, isLoading } = useQuery({
|
||||
queryKey: ['activities', sport, page],
|
||||
queryKey: ['activities', sport, page, fromParam, toParam],
|
||||
queryFn: () =>
|
||||
api.get('/activities/', {
|
||||
params: {
|
||||
sport_type: sport === 'all' ? undefined : sport,
|
||||
page,
|
||||
per_page: 20,
|
||||
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
|
||||
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
|
||||
},
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: ytdStats } = useQuery({
|
||||
queryKey: ['ytd-stats'],
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
|
||||
const clearDateFilter = () => navigate('/activities')
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||
<Link
|
||||
to="/upload"
|
||||
@@ -37,6 +52,28 @@ export default function ActivitiesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* YTD stats */}
|
||||
{ytdStats && (
|
||||
<div className="flex gap-4 mb-4 text-sm">
|
||||
{ytdStats.running_km > 0 && (
|
||||
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
{ytdStats.cycling_km > 0 && (
|
||||
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date filter chip */}
|
||||
{fromParam && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
|
||||
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
|
||||
</span>
|
||||
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">✕ Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sport filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{SPORTS.map(s => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import {
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '../utils/format'
|
||||
|
||||
function WeeklyChart({ activities }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!activities?.length) return (
|
||||
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
||||
)
|
||||
@@ -23,26 +25,39 @@ function WeeklyChart({ activities }) {
|
||||
|
||||
const data = weeks.map(weekStart => {
|
||||
const weekKey = format(weekStart, 'MMM d')
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||
const weekEnd = addDays(weekStart, 7)
|
||||
const km = activities
|
||||
.filter(a => {
|
||||
const t = new Date(a.start_time)
|
||||
return t >= weekStart && t < weekEnd
|
||||
})
|
||||
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
||||
return { week: weekKey, km: parseFloat(km.toFixed(2)) }
|
||||
return {
|
||||
week: weekKey,
|
||||
km: parseFloat(km.toFixed(2)),
|
||||
weekStartISO: format(weekStart, 'yyyy-MM-dd'),
|
||||
weekEndISO: format(weekEnd, 'yyyy-MM-dd'),
|
||||
}
|
||||
})
|
||||
|
||||
const handleBarClick = (entry) => {
|
||||
if (entry?.activePayload?.[0]?.payload) {
|
||||
const { weekStartISO, weekEndISO } = entry.activePayload[0].payload
|
||||
navigate(`/activities?from=${weekStartISO}&to=${weekEndISO}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
|
||||
onClick={handleBarClick} style={{ cursor: 'pointer' }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||
tickFormatter={v => `${v.toFixed(0)}`} />
|
||||
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
|
||||
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']}
|
||||
cursor={{ fill: 'rgba(59,130,246,0.1)' }} />
|
||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -73,8 +88,12 @@ export default function DashboardPage() {
|
||||
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: ytdStats } = useQuery({
|
||||
queryKey: ['ytd-stats'],
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
|
||||
const latest = healthSummary?.latest
|
||||
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -84,8 +103,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
|
||||
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
|
||||
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
|
||||
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
|
||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ function BodyBatteryChart({ bb }) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4 h-full">
|
||||
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
@@ -340,22 +340,26 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 24-hour heart rate chart */}
|
||||
{intradayHr?.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
|
||||
{day.avg_hr_day && (
|
||||
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
|
||||
)}
|
||||
</div>
|
||||
<IntradayHrChart values={intradayHr} />
|
||||
{/* 24-hour heart rate chart + body battery (side by side) */}
|
||||
{(intradayHr?.length > 0 || bodyBattery) && (
|
||||
<div className={`grid gap-4 ${intradayHr?.length > 0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{intradayHr?.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
|
||||
{day.avg_hr_day && (
|
||||
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<IntradayHrChart values={intradayHr} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BodyBatteryChart bb={bodyBattery} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body battery */}
|
||||
<BodyBatteryChart bb={bodyBattery} />
|
||||
|
||||
{/* Activity strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, formatDistance } from '../utils/format'
|
||||
|
||||
function formatSegmentDist(m) {
|
||||
if (m == null) return '--'
|
||||
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
|
||||
}
|
||||
|
||||
function SegmentRow({ seg, routeId, onDeleted }) {
|
||||
const [showTimes, setShowTimes] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: times, isLoading: timesLoading } = useQuery({
|
||||
queryKey: ['segment-times', routeId, seg.id],
|
||||
queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data),
|
||||
enabled: showTimes,
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
|
||||
if (onDeleted) onDeleted()
|
||||
},
|
||||
})
|
||||
|
||||
const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null
|
||||
const lastTime = times?.[0]?.duration_s ?? null
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
|
||||
{seg.auto_generated && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">auto</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)}
|
||||
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-right flex-shrink-0">
|
||||
{showTimes && !timesLoading && bestTime && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Best</p>
|
||||
<p className="text-sm font-mono font-semibold text-yellow-400">{formatDuration(bestTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
{showTimes && !timesLoading && lastTime && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Last</p>
|
||||
<p className="text-sm font-mono text-gray-300">{formatDuration(lastTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowTimes(v => !v)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
|
||||
>
|
||||
{showTimes ? 'Hide' : 'Times'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMut.mutate()}
|
||||
disabled={deleteMut.isPending}
|
||||
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
|
||||
title="Delete segment"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTimes && (
|
||||
<div className="pl-1">
|
||||
{timesLoading && <p className="text-xs text-gray-600">Loading times…</p>}
|
||||
{!timesLoading && !times?.length && (
|
||||
<p className="text-xs text-gray-600">No times recorded yet</p>
|
||||
)}
|
||||
{times?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{times.map((t, i) => (
|
||||
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
|
||||
<span className={`font-mono font-semibold w-14 ${i === 0 && t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||
{formatDuration(t.duration_s)}
|
||||
</span>
|
||||
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
|
||||
{t.name}
|
||||
</Link>
|
||||
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSegmentForm({ routeId, onCreated }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [startKm, setStartKm] = useState('')
|
||||
const [endKm, setEndKm] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: (data) => api.post(`/routes/${routeId}/segments`, data).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
|
||||
setName(''); setStartKm(''); setEndKm(''); setOpen(false)
|
||||
if (onCreated) onCreated()
|
||||
},
|
||||
})
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full text-left text-xs text-blue-400 hover:text-blue-300 border border-dashed border-blue-500/30 hover:border-blue-400/50 rounded-lg px-3 py-2 transition-colors"
|
||||
>
|
||||
+ Add segment manually
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
const start = parseFloat(startKm) * 1000
|
||||
const end = parseFloat(endKm) * 1000
|
||||
if (!name || isNaN(start) || isNaN(end) || end <= start) return
|
||||
mut.mutate({ name, start_distance_m: start, end_distance_m: end })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border border-gray-700 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-gray-400 font-medium">New segment</p>
|
||||
<input
|
||||
type="text" placeholder="Name (e.g. The big hill)"
|
||||
value={name} onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number" placeholder="Start (km)" step="0.01" min="0"
|
||||
value={startKm} onChange={e => setStartKm(e.target.value)}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number" placeholder="End (km)" step="0.01" min="0"
|
||||
value={endKm} onChange={e => setEndKm(e.target.value)}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={mut.isPending}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm py-1.5 rounded transition-colors">
|
||||
{mut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)}
|
||||
className="px-4 text-sm text-gray-500 hover:text-gray-300 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SegmentsPage() {
|
||||
const [selectedRouteId, setSelectedRouteId] = useState(null)
|
||||
const [autoGenLoading, setAutoGenLoading] = useState(null)
|
||||
const [hillGradient, setHillGradient] = useState(5)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: routes } = useQuery({
|
||||
queryKey: ['routes'],
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
const selectedRoute = routes?.find(r => r.id === selectedRouteId)
|
||||
|
||||
const { data: segments, isLoading: segsLoading } = useQuery({
|
||||
queryKey: ['segments', selectedRouteId],
|
||||
queryFn: () => api.get(`/routes/${selectedRouteId}/segments`).then(r => r.data),
|
||||
enabled: !!selectedRouteId,
|
||||
})
|
||||
|
||||
const autoGenMut = useMutation({
|
||||
mutationFn: ({ type, opts }) =>
|
||||
api.post(`/routes/${selectedRouteId}/segments/auto`, { type, ...opts }).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['segments', selectedRouteId] })
|
||||
setAutoGenLoading(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
alert(err?.response?.data?.detail || 'Auto-generate failed')
|
||||
setAutoGenLoading(null)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAutoGen = (type, opts = {}) => {
|
||||
setAutoGenLoading(type)
|
||||
autoGenMut.mutate({ type, opts })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Segments</h1>
|
||||
</div>
|
||||
|
||||
{/* Route selector */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<label className="block text-xs text-gray-500 mb-2">Select a route</label>
|
||||
{!routes?.length ? (
|
||||
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedRouteId ?? ''}
|
||||
onChange={e => setSelectedRouteId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">— choose a route —</option>
|
||||
{routes.map(r => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}{r.distance_m ? ` (${(r.distance_m / 1000).toFixed(1)} km)` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRoute && (
|
||||
<div className="space-y-4">
|
||||
{/* Route info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{selectedRoute.name}</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
|
||||
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
|
||||
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-generate controls */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-gray-400">Auto-generate segments</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<button
|
||||
onClick={() => handleAutoGen('1km')}
|
||||
disabled={autoGenLoading === '1km'}
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoGenLoading === '1km' ? 'Generating…' : '📏 1 km splits'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAutoGen('turns')}
|
||||
disabled={autoGenLoading === 'turns'}
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-purple-600/20 text-purple-300 border border-purple-500/30 hover:bg-purple-600/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoGenLoading === 'turns' ? 'Generating…' : '↩️ Detect turns'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleAutoGen('hills', { gradient_pct: hillGradient })}
|
||||
disabled={autoGenLoading === 'hills'}
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-green-600/20 text-green-300 border border-green-500/30 hover:bg-green-600/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoGenLoading === 'hills' ? 'Generating…' : '⛰️ Detect hills'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500">≥</span>
|
||||
<input
|
||||
type="number" min="1" max="30" step="1"
|
||||
value={hillGradient}
|
||||
onChange={e => setHillGradient(parseInt(e.target.value) || 5)}
|
||||
className="w-12 bg-gray-800 border border-gray-700 text-white text-xs rounded px-2 py-1 text-center focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Auto-generate replaces previously auto-generated segments. Manual segments are kept.</p>
|
||||
</div>
|
||||
|
||||
{/* Segments list */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
||||
{segments?.length > 0 && (
|
||||
<span className="text-xs text-gray-600">{segments.length} segment{segments.length !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{segsLoading && <p className="text-sm text-gray-600">Loading…</p>}
|
||||
|
||||
{!segsLoading && !segments?.length && (
|
||||
<p className="text-sm text-gray-600">No segments yet. Use auto-generate above or add one manually.</p>
|
||||
)}
|
||||
|
||||
{segments?.map(seg => (
|
||||
<SegmentRow key={seg.id} seg={seg} routeId={selectedRouteId} />
|
||||
))}
|
||||
|
||||
<NewSegmentForm routeId={selectedRouteId} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user