Files
MileVault/frontend/src/pages/ProfilePage.jsx
T
owain 568dc31e97
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
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>
2026-06-07 13:14:00 +01:00

437 lines
19 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
function Section({ title, children }) {
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
{children}
</div>
)
}
function Field({ label, hint, children }) {
return (
<div>
<label className="text-xs text-gray-400 block mb-1">{label}</label>
{children}
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
</div>
)
}
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
return (
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
)
}
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
return (
<div className="flex items-center gap-3 pt-1">
<button onClick={onClick} disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{loading ? 'Saving…' : label}
</button>
{saved && <span className="text-green-400 text-sm"> Saved</span>}
</div>
)
}
export default function ProfilePage() {
const qc = useQueryClient()
const { user } = useAuthStore()
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data),
})
const { data: pocketidConfig } = useQuery({
queryKey: ['pocketid-config'],
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
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: '', 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 || '',
birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '',
})
}, [profile])
const updateProfile = useMutation({
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['profile'] })
setHrSaved(true)
setTimeout(() => setHrSaved(false), 3000)
if (maxHrChangedRef.current) {
setHrZoneRecalc(true)
setTimeout(() => setHrZoneRecalc(false), 6000)
maxHrChangedRef.current = false
}
},
})
// Password change
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
const [pwError, setPwError] = useState('')
const [pwSaved, setPwSaved] = useState(false)
const changePassword = useMutation({
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
})
// Garmin Connect sync
const { data: garminConfig, refetch: refetchGarmin } = useQuery({
queryKey: ['garmin-config'],
queryFn: () => api.get('/garmin-sync/config').then(r => r.data),
})
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: 30 })
const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('')
const [gcSyncing, setGcSyncing] = useState(false)
const syncPollRef = useRef(null)
useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, [])
useEffect(() => {
if (garminConfig?.connected) {
setGcForm(f => ({
...f,
email: garminConfig.email || '',
sync_enabled: garminConfig.sync_enabled,
sync_activities: garminConfig.sync_activities,
sync_wellness: garminConfig.sync_wellness,
sync_lookback_days: garminConfig.sync_lookback_days ?? 30,
}))
}
}, [garminConfig])
const saveGarmin = useMutation({
mutationFn: data => api.put('/garmin-sync/config', data).then(r => r.data),
onSuccess: () => {
refetchGarmin()
setGcSaved(true)
setGcError('')
setGcForm(f => ({ ...f, password: '' }))
setTimeout(() => setGcSaved(false), 3000)
},
onError: e => setGcError(e.response?.data?.detail || 'Failed to save'),
})
const deleteGarmin = useMutation({
mutationFn: () => api.delete('/garmin-sync/config'),
onSuccess: () => {
refetchGarmin()
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: 30 })
},
})
const triggerGarminSync = async () => {
setGcSyncing(true)
try {
await api.post('/garmin-sync/trigger')
// Poll every 2s: wait until we've seen an in-progress status, then wait for terminal
let seenInProgress = false
syncPollRef.current = setInterval(async () => {
const result = await refetchGarmin()
const status = result.data?.last_sync_status ?? ''
const terminal = status.startsWith('OK') || status.startsWith('Partial') || status.startsWith('Auth error')
if (!terminal) seenInProgress = true
if (seenInProgress && terminal) {
clearInterval(syncPollRef.current)
syncPollRef.current = null
setGcSyncing(false)
}
}, 2000)
// Safety: stop polling after 10 minutes regardless
setTimeout(() => {
if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null }
setGcSyncing(false)
}, 600000)
} catch {
setGcSyncing(false)
}
}
const syncProgressPct = status => {
if (!status) return 5
if (status.startsWith('Connecting')) return 10
if (status.startsWith('Syncing activities')) return 35
if (status.startsWith('Syncing wellness')) return 70
return 5
}
// PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
const [pidSaved, setPidSaved] = useState(false)
useEffect(() => {
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
}, [pocketidConfig])
const savePocketID = useMutation({
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
})
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
return (
<div className="p-6 max-w-2xl space-y-6">
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
{/* HR & Measurements */}
<Section title="Heart Rate & Measurements">
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
{effectiveMaxHr && (
<div className="mt-2 text-white">
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
{!profile?.max_heart_rate && ' (estimated from age)'}
{' · '}Zones: Z1 &lt;{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}{Math.round(effectiveMaxHr * 0.9)}, Z5 &gt;{Math.round(effectiveMaxHr * 0.9)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
<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="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 }))} />
</Field>
<Field label="Height (cm)">
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
</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(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
)
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
updateProfile.mutate(data)
}}
loading={updateProfile.isPending}
saved={hrSaved}
/>
{hrZoneRecalc && (
<p className="text-xs text-blue-400 mt-1">HR zones are being recalculated for your existing activities.</p>
)}
</Section>
{/* Password change */}
<Section title="Change Password">
<div className="space-y-3">
<Field label="Current password">
<Input type="password" value={pwForm.current_password}
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
</Field>
<Field label="New password (min 8 characters)">
<Input type="password" value={pwForm.new_password}
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
</Field>
<Field label="Confirm new password">
<Input type="password" value={pwForm.confirm}
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
</Field>
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
</div>
<SaveButton
onClick={() => {
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
}}
loading={changePassword.isPending}
saved={pwSaved}
label="Change password"
/>
</Section>
{/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500">
Connect your Garmin account to automatically import new activities and wellness data every hour.
Credentials are encrypted at rest.
</p>
{garminConfig?.connected && (
<div className="flex items-center justify-between bg-green-900/20 border border-green-800/40 rounded-lg px-3 py-2 text-xs">
<span className="text-green-400"> Connected as {garminConfig.email}</span>
<div className="flex items-center gap-3">
{garminConfig.last_sync_at && (
<span className="text-gray-500">
Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
)}
{garminConfig.last_sync_status && (
<span className={garminConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : garminConfig.last_sync_status.startsWith('Auth') ? 'text-red-400' : 'text-yellow-400'}>
{garminConfig.last_sync_status}
</span>
)}
</div>
</div>
)}
<div className="space-y-3">
<Field label="Garmin Connect email">
<Input value={gcForm.email} placeholder="you@example.com"
onChange={e => setGcForm(f => ({ ...f, email: e.target.value }))} />
</Field>
<Field label={garminConfig?.connected ? 'Password (leave blank to keep existing)' : 'Password'}>
<Input type="password" value={gcForm.password} placeholder="••••••••"
onChange={e => setGcForm(f => ({ ...f, password: e.target.value }))} />
</Field>
<div className="flex flex-wrap gap-4 pt-1">
{[
['sync_enabled', 'Enable hourly sync'],
['sync_activities', 'Sync activities (FIT download)'],
['sync_wellness', 'Sync wellness data'],
].map(([key, label]) => (
<label key={key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={gcForm[key]}
onChange={e => setGcForm(f => ({ ...f, [key]: e.target.checked }))}
className="w-4 h-4 accent-blue-500" />
<span className="text-sm text-gray-300">{label}</span>
</label>
))}
</div>
<Field label="Sync lookback days" hint="-1 syncs all available history (back to 2010). Leave at 30 for incremental syncs.">
<Input type="number" value={gcForm.sync_lookback_days} min={-1}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: parseInt(e.target.value, 10) || 30 }))} />
{gcForm.sync_lookback_days > 365 && gcForm.sync_lookback_days !== -1 && (
<p className="text-yellow-400 text-xs mt-1">Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.</p>
)}
</Field>
</div>
{gcError && <p className="text-red-400 text-xs">{gcError}</p>}
<div className="flex items-center gap-3 flex-wrap pt-1">
<SaveButton
onClick={() => {
if (!garminConfig?.connected && !gcForm.password) {
setGcError('Password is required for first-time setup')
return
}
const payload = { ...gcForm }
if (!payload.password) delete payload.password
saveGarmin.mutate(payload)
}}
loading={saveGarmin.isPending}
saved={gcSaved}
label={garminConfig?.connected ? 'Update' : 'Connect'}
/>
{garminConfig?.connected && (
<>
<button
onClick={triggerGarminSync}
disabled={gcSyncing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
</button>
<button
onClick={() => { if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
className="text-red-400 hover:text-red-300 text-sm transition-colors">
Disconnect
</button>
</>
)}
</div>
{gcSyncing && (
<div className="space-y-1.5">
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${syncProgressPct(garminConfig?.last_sync_status)}%` }}
/>
</div>
<p className="text-xs text-blue-400">
{garminConfig?.last_sync_status || 'Starting sync…'}
</p>
</div>
)}
</Section>
{/* PocketID — admin only */}
{user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)">
<p className="text-xs text-gray-500">
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
</p>
<div className="space-y-3">
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
</Field>
<Field label="Client ID">
<Input value={pidForm.client_id} placeholder="milevault"
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
</Field>
<Field label="Client secret" hint="Leave blank to keep existing secret">
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
</Field>
{pocketidConfig?.enabled && (
<p className="text-xs text-green-400"> PocketID is currently active</p>
)}
</div>
<SaveButton
onClick={() => savePocketID.mutate(pidForm)}
loading={savePocketID.isPending}
saved={pidSaved}
label="Save PocketID config"
/>
</Section>
)}
</div>
)
}