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
co-authored by Claude Sonnet 4.6
parent 02eccad578
commit 568dc31e97
8 changed files with 602 additions and 199 deletions
+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>
)
}