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
+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">