347 lines
14 KiB
React
347 lines
14 KiB
React
import { useState, Fragment } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
|
import { format } from 'date-fns'
|
|
import api from '../utils/api'
|
|
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
|
import { useUnit } from '../hooks/useUnits'
|
|
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
|
|
|
const SPORTS = ['running', 'cycling']
|
|
|
|
const DISTANCE_ORDER = [
|
|
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
|
|
'Half marathon', 'Marathon', '50k', '100k',
|
|
]
|
|
|
|
const TABS = ['Distance PRs', 'Route Records', 'Segments']
|
|
|
|
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
|
|
|
function DistancePRs() {
|
|
const [sport, setSport] = useState('running')
|
|
const [selectedDistance, setSelectedDistance] = useState(null)
|
|
|
|
const { data: records } = useQuery({
|
|
queryKey: ['records', sport],
|
|
queryFn: () => api.get('/records/', { params: { sport_type: sport } }).then(r => r.data),
|
|
})
|
|
|
|
const { data: history } = useQuery({
|
|
queryKey: ['record-history', selectedDistance, sport],
|
|
queryFn: () =>
|
|
api.get(`/records/history/${encodeURIComponent(selectedDistance)}`, {
|
|
params: { sport_type: sport },
|
|
}).then(r => r.data),
|
|
enabled: !!selectedDistance,
|
|
})
|
|
|
|
const sortedRecords = records?.slice().sort((a, b) => {
|
|
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
|
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
|
})
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{SPORTS.map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => { setSport(s); setSelectedDistance(null) }}
|
|
className={`capitalize text-sm px-4 py-1.5 rounded-full border transition-colors ${
|
|
sport === s
|
|
? 'bg-blue-600 border-blue-600 text-white'
|
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
|
}`}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{sortedRecords?.length === 0 && (
|
|
<div className="text-center py-16 text-gray-600">
|
|
<p className="text-4xl mb-3">🏆</p>
|
|
<p>No records yet — import activities to track your best times</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
|
<th className="text-left px-4 py-3 font-medium">Distance</th>
|
|
<th className="text-right px-4 py-3 font-medium">Best time</th>
|
|
<th className="text-right px-4 py-3 font-medium">Date</th>
|
|
<th className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedRecords?.map(rec => (
|
|
<tr
|
|
key={rec.id}
|
|
onClick={() => setSelectedDistance(rec.distance_label)}
|
|
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
|
selectedDistance === rec.distance_label ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
|
|
}`}
|
|
>
|
|
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
|
<td className="px-4 py-3 text-right font-mono text-yellow-400 font-semibold">
|
|
{formatDuration(rec.duration_s)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-gray-400 text-xs">
|
|
{formatDate(rec.achieved_at)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<Link
|
|
to={`/activities/${rec.activity_id}`}
|
|
onClick={e => e.stopPropagation()}
|
|
className="text-xs text-blue-400 hover:underline"
|
|
>
|
|
View →
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
|
{selectedDistance && history ? (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-300 mb-1">{selectedDistance} progression</h3>
|
|
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
|
{history.length > 1 ? (
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<LineChart
|
|
data={history.map(h => ({ date: h.achieved_at, time: h.duration_s }))}
|
|
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
|
tickFormatter={d => format(new Date(d), 'MMM yy')} />
|
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
|
width={40} tickFormatter={formatDuration} />
|
|
<Tooltip
|
|
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
|
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
|
formatter={v => [formatDuration(v), 'Time']}
|
|
/>
|
|
<Line type="monotone" dataKey="time" stroke="#fbbf24" strokeWidth={2}
|
|
dot={{ fill: '#fbbf24', r: 4 }} isAnimationActive={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
|
|
Only one record — complete more activities to see progression
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
|
|
Select a distance to see your progression
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RouteRecords() {
|
|
const navigate = useNavigate()
|
|
const unit = useUnit()
|
|
const { data: records, isLoading } = useQuery({
|
|
queryKey: ['route-records'],
|
|
queryFn: () => api.get('/records/routes').then(r => r.data),
|
|
})
|
|
|
|
if (isLoading) return <p className="text-gray-500 text-sm">Loading…</p>
|
|
|
|
if (!records?.length) return (
|
|
<div className="text-center py-16 text-gray-600">
|
|
<p className="text-4xl mb-3">🗺️</p>
|
|
<p>No route records yet — create named routes and complete activities on them</p>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
|
<th className="px-3 py-3" />
|
|
<th className="text-left px-3 py-3 font-medium">Route</th>
|
|
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
|
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
|
<th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Pace</th>
|
|
<th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.map(rec => (
|
|
<tr
|
|
key={rec.route_id}
|
|
onClick={() => navigate(`/activities/${rec.activity_id}`)}
|
|
className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer"
|
|
>
|
|
<td className="px-3 py-2">
|
|
<RouteMiniMap polyline={rec.reference_polyline} sportType={rec.sport_type} width={72} height={50} />
|
|
</td>
|
|
<td className="px-3 py-3 font-medium text-white">
|
|
<span className="capitalize text-xs text-gray-500 mr-2">{rec.sport_type}</span>
|
|
{rec.route_name}
|
|
</td>
|
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
|
{formatDistance(rec.distance_m, unit)}
|
|
</td>
|
|
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
|
{formatDuration(rec.duration_s)}
|
|
</td>
|
|
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
|
{formatPace(rec.avg_speed_ms, rec.sport_type, unit)}
|
|
</td>
|
|
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
|
{formatDate(rec.start_time)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SegmentLeaderboard({ segmentId }) {
|
|
const { data } = useQuery({
|
|
queryKey: ['segment', segmentId],
|
|
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
|
|
})
|
|
if (!data) return <p className="text-xs text-gray-600 py-2 px-4">Loading…</p>
|
|
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2 px-4">No efforts yet — still matching.</p>
|
|
return (
|
|
<div className="px-4 py-2 space-y-0.5 bg-gray-950/40">
|
|
{data.leaderboard.map((e, i) => (
|
|
<div key={e.activity_id} className="flex items-center gap-2 text-xs">
|
|
<span className="w-6 text-right">{MEDALS[e.rank] || i + 1}</span>
|
|
<span className="font-mono text-gray-200 w-16 text-right">{formatDuration(e.duration_s)}</span>
|
|
<Link to={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">
|
|
{e.activity_name}
|
|
</Link>
|
|
{e.date && <span className="text-gray-600">{formatDate(e.date)}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SegmentRecords() {
|
|
const [open, setOpen] = useState(null)
|
|
const unit = useUnit()
|
|
const { data: segments, isLoading } = useQuery({
|
|
queryKey: ['segments'],
|
|
queryFn: () => api.get('/segments/').then(r => r.data),
|
|
})
|
|
|
|
if (isLoading) return <p className="text-gray-500 text-sm">Loading…</p>
|
|
|
|
if (!segments?.length) return (
|
|
<div className="text-center py-16 text-gray-600">
|
|
<p className="text-4xl mb-3">🏅</p>
|
|
<p>No segments yet — create one from an activity's detail page</p>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
|
<th className="px-3 py-3" />
|
|
<th className="text-left px-3 py-3 font-medium">Segment</th>
|
|
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
|
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
|
<th className="text-right px-3 py-3 font-medium">Efforts</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{segments.map(seg => (
|
|
<Fragment key={seg.id}>
|
|
<tr
|
|
onClick={() => setOpen(open === seg.id ? null : seg.id)}
|
|
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
|
open === seg.id ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
|
|
}`}
|
|
>
|
|
<td className="px-3 py-2">
|
|
<RouteMiniMap polyline={seg.polyline} sportType={seg.sport_type} width={72} height={50} />
|
|
</td>
|
|
<td className="px-3 py-3 font-medium text-white">
|
|
{seg.sport_type && <span className="capitalize text-xs text-gray-500 mr-2">{seg.sport_type}</span>}
|
|
{seg.name}
|
|
</td>
|
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
|
{formatDistance(seg.distance_m, unit)}
|
|
</td>
|
|
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
|
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
|
</td>
|
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
|
{seg.effort_count}
|
|
</td>
|
|
</tr>
|
|
{open === seg.id && (
|
|
<tr>
|
|
<td colSpan={5} className="p-0 border-b border-gray-800/50">
|
|
<SegmentLeaderboard segmentId={seg.id} />
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function RecordsPage() {
|
|
const [tab, setTab] = useState('Distance PRs')
|
|
|
|
return (
|
|
<div className="p-4 md:p-6 space-y-6">
|
|
<h1 className="text-2xl font-bold text-white">Records</h1>
|
|
|
|
<div className="flex gap-2 flex-wrap">
|
|
{TABS.map(t => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`text-sm px-4 py-1.5 rounded-full border transition-colors ${
|
|
tab === t
|
|
? 'bg-blue-600 border-blue-600 text-white'
|
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'Distance PRs' && <DistancePRs />}
|
|
{tab === 'Route Records' && <RouteRecords />}
|
|
{tab === 'Segments' && <SegmentRecords />}
|
|
</div>
|
|
)
|
|
}
|