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>
)
}