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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user