568dc31e97
- 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>
437 lines
19 KiB
React
437 lines
19 KiB
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'
|
||
|
||
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 <{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 >{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>
|
||
)
|
||
}
|