Round 2: body battery redesign, profile cleanup, segment integration, route/segment records
- 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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user