Round 2: body battery redesign, profile cleanup, segment integration, route/segment records
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 31s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Successful in 34s

- Body battery: replace circular ring with compact full-height colored bar chart,
  level as line overlay, legend shows only types present in data
- Dashboard: add mini body battery summary card above health today panel
- Profile: remove editable resting HR and manual weight log; show 7-day avg
  resting HR and latest Garmin weight as read-only
- Backend: add GET /routes/{id}/segment-bests bulk endpoint (fetches all matched
  activity data points in one query, computes best segment time per segment)
- Backend: add GET /records/routes for fastest activity per named route
- Routes page: add Segments panel to route detail (grouped as 1km splits vs
  hills/turns, best times, delete, theoretical best)
- Activity detail page: show segment times computed client-side from data points,
  🏆 badge if new best
- Records page: add Route Records and Segment Records tabs alongside Distance PRs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:14:00 +01:00
parent 02eccad578
commit 568dc31e97
8 changed files with 602 additions and 199 deletions
+59 -1
View File
@@ -1,4 +1,4 @@
import { useParams } from 'react-router-dom'
import { useParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useState, useMemo } from 'react'
import api from '../utils/api'
@@ -12,6 +12,16 @@ import {
formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format'
function segmentTime(points, startM, endM) {
let t0 = null
for (const p of points) {
if (t0 === null && p.distance_m >= startM) t0 = new Date(p.timestamp).getTime()
if (t0 !== null && p.distance_m >= endM)
return (new Date(p.timestamp).getTime() - t0) / 1000
}
return null
}
const METRICS = [
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
@@ -45,6 +55,18 @@ export default function ActivityDetailPage() {
enabled: !!activity,
})
const { data: segments } = useQuery({
queryKey: ['segments', activity?.named_route_id],
queryFn: () => api.get(`/routes/${activity.named_route_id}/segments`).then(r => r.data),
enabled: !!activity?.named_route_id,
})
const { data: segmentBests } = useQuery({
queryKey: ['segment-bests', activity?.named_route_id],
queryFn: () => api.get(`/routes/${activity.named_route_id}/segment-bests`).then(r => r.data),
enabled: !!activity?.named_route_id,
})
const toggleMetric = (key) => {
setActiveMetrics(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
@@ -192,6 +214,42 @@ export default function ActivityDetailPage() {
<LapTable laps={laps} sportType={activity.sport_type} />
</div>
)}
{/* Segments */}
{segments && segments.length > 0 && dataPoints && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
<div className="space-y-1">
{segments.map(seg => {
const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
const best = segmentBests?.find(b => b.segment_id === seg.id)
const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5
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="font-mono text-xs w-14 text-right">
{t != null ? (
<span className={isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}>
{formatDuration(t)}
</span>
) : (
<span className="text-gray-700">--</span>
)}
</span>
{isNewBest && <span className="text-xs" title="New best">🏆</span>}
{!isNewBest && best?.best_s != null && (
<span className="text-gray-600 text-xs w-14 text-right">/{formatDuration(best.best_s)}</span>
)}
{!isNewBest && !best?.best_s && <span className="w-14" />}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
+60 -21
View File
@@ -9,6 +9,42 @@ import {
formatDate, sportIcon, formatSleep,
} from '../utils/format'
function bbLevelColor(level) {
if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6'
if (level >= 50) return '#22c55e'
if (level >= 25) return '#f59e0b'
return '#ef4444'
}
function MiniBodyBattery({ bb }) {
if (!bb?.end_level && !bb?.charged) return null
const { charged, drained, start_level, end_level } = bb
const color = bbLevelColor(end_level)
return (
<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">Body Battery</h3>
<Link to="/health" className="text-xs text-blue-400 hover:underline">View </Link>
</div>
<div className="flex items-baseline gap-3 flex-wrap">
{end_level != null && (
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</span>
)}
{charged != null && (
<span className="text-sm font-semibold text-green-400">+{charged}</span>
)}
{drained != null && (
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
)}
</div>
{start_level != null && end_level != null && (
<p className="text-xs text-gray-500 mt-1">{start_level} {end_level} today</p>
)}
</div>
)
}
function WeeklyChart({ activities }) {
const navigate = useNavigate()
@@ -115,27 +151,30 @@ export default function DashboardPage() {
<WeeklyChart activities={allActivities} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? (
<>
{[
['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
['Steps', latest.steps?.toLocaleString() ?? '--'],
['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
].map(([label, val]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span>
<span className="text-white">{val}</span>
</div>
))}
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard </Link>
</>
) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
)}
<div className="flex flex-col gap-4">
<MiniBodyBattery bb={latest?.body_battery} />
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? (
<>
{[
['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
['Steps', latest.steps?.toLocaleString() ?? '--'],
['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
].map(([label, val]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span>
<span className="text-white">{val}</span>
</div>
))}
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard </Link>
</>
) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
)}
</div>
</div>
</div>
+45 -67
View File
@@ -71,88 +71,66 @@ function bbLevelColor(level) {
return '#ef4444'
}
function BatteryRing({ level }) {
if (level == null) return <span className="text-3xl font-bold text-gray-600">--</span>
const r = 38, stroke = 8
const c = 2 * Math.PI * r
const filled = c * (Math.min(100, Math.max(0, level)) / 100)
const color = bbLevelColor(level)
return (
<svg width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r={r} fill="none" stroke="#1f2937" strokeWidth={stroke} />
<circle cx="48" cy="48" r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={`${filled} ${c - filled}`} strokeLinecap="round"
transform="rotate(-90 48 48)" />
<text x="48" y="44" textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize="20" fontWeight="bold">{Math.round(level)}</text>
<text x="48" y="62" textAnchor="middle" fill="#6b7280" fontSize="11">/ 100</text>
</svg>
)
}
function BodyBatteryChart({ bb }) {
if (!bb) return null
const { charged, drained, start_level, end_level, values } = bb
if (!values?.length && end_level == null) return null
const chartData = (values || []).map(([ts, level, type, stress]) => ({
const chartData = (values || []).map(([ts, level, type]) => ({
t: ts,
level,
type: type ?? 4,
bar: stress > 0 ? stress : (type === 2 ? 8 : type === 0 ? 20 : 35),
bar: 100,
}))
return (
<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>
const presentTypes = [...new Set(chartData.map(d => d.type))]
const levelColor = bbLevelColor(end_level)
<div className="flex items-center gap-8">
<BatteryRing level={end_level} />
<div className="space-y-3">
{charged != null && (
<div>
<p className="text-xs text-gray-500">Charged</p>
<span className="text-xl font-semibold text-blue-400">+{charged}</span>
</div>
)}
{drained != null && (
<div>
<p className="text-xs text-gray-500">Drained</p>
<span className="text-xl font-semibold text-orange-400">-{drained}</span>
</div>
)}
{start_level != null && end_level != null && (
<p className="text-xs text-gray-500">{start_level} {end_level}</p>
)}
</div>
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
<div className="flex items-baseline gap-3 flex-wrap mb-3">
{end_level != null && (
<span className="text-3xl font-bold" style={{ color: levelColor }}>{Math.round(end_level)}</span>
)}
{charged != null && (
<span className="text-sm font-semibold text-green-400">+{charged}</span>
)}
{drained != null && (
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
)}
{start_level != null && end_level != null && (
<span className="text-xs text-gray-500">{start_level} {end_level}</span>
)}
</div>
{chartData.length > 0 && (
<>
<ResponsiveContainer width="100%" height={110}>
<ComposedChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(chartData.length / 6))} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={28} domain={[0, 100]} />
<Tooltip contentStyle={tooltipStyle}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : [Math.round(v), 'Stress']} />
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={8}>
{chartData.map((d, i) => (
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.7} />
))}
</Bar>
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={2}
dot={false} isAnimationActive={false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{Object.entries(BB_TYPE_LABEL).map(([code, label]) => (
<div key={code} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
<span className="text-xs text-gray-400">{label}</span>
<div className="flex-1">
<ResponsiveContainer width="100%" height={100}>
<ComposedChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 0 }}>
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(chartData.length / 6))} />
<Tooltip contentStyle={tooltipStyle}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
{chartData.map((d, i) => (
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.8} />
))}
</Bar>
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
dot={false} isAnimationActive={false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{presentTypes.map(code => (
<div key={code} className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
<span className="text-xs text-gray-500">{BB_TYPE_LABEL[code]}</span>
</div>
))}
</div>
+36 -68
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
@@ -56,15 +56,31 @@ export default function ProfilePage() {
enabled: !!user?.is_admin,
})
const { data: recentMetrics } = useQuery({
queryKey: ['health-metrics-recent'],
queryFn: () => api.get('/health-metrics/', { params: { limit: 7 } }).then(r => r.data),
})
const { data: healthSummary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const avgRestingHr = useMemo(() => {
if (!recentMetrics?.length) return null
const vals = recentMetrics.filter(m => m.resting_hr != null).map(m => m.resting_hr)
if (!vals.length) return null
return Math.round(vals.reduce((s, v) => s + v, 0) / vals.length)
}, [recentMetrics])
// HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '' })
const [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false)
useEffect(() => {
if (profile) setHrForm({
max_heart_rate: profile.max_heart_rate || '',
resting_heart_rate: profile.resting_heart_rate || '',
birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '',
})
@@ -84,22 +100,6 @@ export default function ProfilePage() {
},
})
// Weight log
const { data: weightLog } = useQuery({
queryKey: ['weight-log'],
queryFn: () => api.get('/profile/weight').then(r => r.data),
})
const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) })
const [weightSaved, setWeightSaved] = useState(false)
const addWeight = useMutation({
mutationFn: data => api.post('/profile/weight', data).then(r => r.data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) },
})
const deleteWeight = useMutation({
mutationFn: id => api.delete(`/profile/weight/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }),
})
// Password change
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
const [pwError, setPwError] = useState('')
@@ -221,10 +221,6 @@ export default function ProfilePage() {
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
</Field>
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
</Field>
<Field label="Birth year" hint="Used to estimate max HR if not set above">
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
@@ -235,6 +231,23 @@ export default function ProfilePage() {
</Field>
</div>
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
<div className="flex gap-6 pt-3 border-t border-gray-800">
{avgRestingHr && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Resting HR (7-day avg, from Garmin)</p>
<span className="text-lg font-semibold text-rose-400">{avgRestingHr} bpm</span>
</div>
)}
{healthSummary?.latest?.weight_kg && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Weight (from Garmin)</p>
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
</div>
)}
</div>
)}
<SaveButton
onClick={() => {
const data = Object.fromEntries(
@@ -251,51 +264,6 @@ export default function ProfilePage() {
)}
</Section>
{/* Weight log */}
<Section title="Weight Log">
<div className="grid grid-cols-3 gap-3">
<Field label="Weight (kg)">
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
</Field>
<Field label="Body fat % (optional)">
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
</Field>
<Field label="Date">
<Input type="datetime-local" value={weightForm.date}
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
</Field>
</div>
<SaveButton
onClick={() => addWeight.mutate({
weight_kg: parseFloat(weightForm.weight_kg),
body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null,
date: new Date(weightForm.date).toISOString(),
})}
loading={addWeight.isPending}
saved={weightSaved}
label="Log weight"
/>
{weightLog && weightLog.length > 0 && (
<div className="mt-2">
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
<div className="space-y-1 max-h-48 overflow-y-auto">
{weightLog.slice(0, 20).map(entry => (
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
<button onClick={() => deleteWeight.mutate(entry.id)}
className="text-gray-700 hover:text-red-400 text-xs transition-colors"></button>
</div>
))}
</div>
</div>
)}
</Section>
{/* Password change */}
<Section title="Change Password">
<div className="space-y-3">
+201 -42
View File
@@ -4,7 +4,7 @@ import { Link } 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 } from '../utils/format'
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
const SPORTS = ['running', 'cycling', 'swimming']
@@ -13,7 +13,9 @@ const DISTANCE_ORDER = [
'Half marathon', 'Marathon', '50k', '100k',
]
export default function RecordsPage() {
const TABS = ['Distance PRs', 'Route Records', 'Segment Records']
function DistancePRs() {
const [sport, setSport] = useState('running')
const [selectedDistance, setSelectedDistance] = useState(null)
@@ -31,7 +33,6 @@ export default function RecordsPage() {
enabled: !!selectedDistance,
})
// Sort by standard distance order
const sortedRecords = records?.slice().sort((a, b) => {
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
@@ -39,10 +40,7 @@ export default function RecordsPage() {
})
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
{/* Sport selector */}
<div className="space-y-4">
<div className="flex gap-2">
{SPORTS.map(s => (
<button
@@ -67,7 +65,6 @@ export default function RecordsPage() {
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Records table */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
@@ -84,9 +81,7 @@ export default function RecordsPage() {
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'
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>
@@ -111,52 +106,29 @@ export default function RecordsPage() {
</table>
</div>
{/* Progress chart */}
<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>
<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,
}))}
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}
reversed
/>
<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} reversed />
<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}
/>
<Line type="monotone" dataKey="time" stroke="#fbbf24" strokeWidth={2}
dot={{ fill: '#fbbf24', r: 4 }} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
) : (
@@ -175,3 +147,190 @@ export default function RecordsPage() {
</div>
)
}
function RouteRecords() {
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">
<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">Route</th>
<th className="text-right 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">Pace</th>
<th className="text-right px-4 py-3 font-medium">Date</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{records.map(rec => (
<tr key={rec.route_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
<td className="px-4 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-4 py-3 text-right text-gray-400 text-xs">
{formatDistance(rec.distance_m)}
</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">
{formatPace(rec.avg_speed_ms, rec.sport_type)}
</td>
<td className="px-4 py-3 text-right text-gray-400 text-xs">
{formatDate(rec.start_time)}
</td>
<td className="px-4 py-3 text-right">
<Link to={`/activities/${rec.activity_id}`} className="text-xs text-blue-400 hover:underline">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function SegmentRecords() {
const [selectedRouteId, setSelectedRouteId] = useState(null)
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const { data: bests, isLoading } = useQuery({
queryKey: ['segment-bests', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segment-bests`).then(r => r.data),
enabled: !!selectedRouteId,
})
const theoreticalBest = bests?.length && bests.every(b => b.best_s != null)
? bests.reduce((sum, b) => sum + b.best_s, 0)
: null
return (
<div className="space-y-4">
<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>
{selectedRouteId && (
isLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : !bests?.length ? (
<p className="text-gray-600 text-sm">No segments for this route. <Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link></p>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<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">Segment</th>
<th className="text-right px-4 py-3 font-medium">Length</th>
<th className="text-right px-4 py-3 font-medium">Best time</th>
<th className="text-right px-4 py-3 font-medium">Runs</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{bests.map(b => (
<tr key={b.segment_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
<td className="px-4 py-3 text-gray-200">
{b.name}
{b.auto_generated && <span className="ml-2 text-xs text-gray-600">(auto)</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">
{formatDistance(b.end_distance_m - b.start_distance_m)}
</td>
<td className="px-4 py-3 text-right font-mono font-semibold">
{b.best_s != null
? <span className="text-yellow-400">{formatDuration(b.best_s)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">{b.count}</td>
<td className="px-4 py-3 text-right">
{b.best_activity_id && (
<Link to={`/activities/${b.best_activity_id}`} className="text-xs text-blue-400 hover:underline">
View
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
{theoreticalBest != null && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
<span className="text-xs text-gray-500">Theoretical best (sum of all segment bests)</span>
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
)}
</div>
)
}
export default function RecordsPage() {
const [tab, setTab] = useState('Distance PRs')
return (
<div className="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 === 'Segment Records' && <SegmentRecords />}
</div>
)
}
+85
View File
@@ -4,6 +4,89 @@ 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 theoreticalBest = bests?.every(b => b.best_s != null)
? bests.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 (sum of segment bests)</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 []
@@ -288,6 +371,8 @@ export default function RoutesPage() {
</Link>
))}
</div>
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
</div>
</div>
)}