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 (

{title}

{children}
) } function Field({ label, hint, children }) { return (
{children} {hint &&

{hint}

}
) } function Input({ type = 'text', value, onChange, placeholder, min, max }) { return ( ) } function SaveButton({ onClick, loading, saved, label = 'Save' }) { return (
{saved && ✓ Saved}
) } 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 (

Profile & Settings

{/* HR & Measurements */}
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test. {effectiveMaxHr && (
Effective max HR: {effectiveMaxHr} bpm {!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)}
)}
setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} /> setHrForm(f => ({ ...f, birth_year: e.target.value }))} /> setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
{avgRestingHr && (

Resting HR (7-day avg, from Garmin)

{avgRestingHr} bpm
)} {healthSummary?.latest?.weight_kg && (

Weight (from Garmin)

{healthSummary.latest.weight_kg.toFixed(1)} kg
)}
)} { 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 && (

HR zones are being recalculated for your existing activities.

)}
{/* Password change */}
{ setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} /> setPwForm(f => ({ ...f, new_password: e.target.value }))} /> setPwForm(f => ({ ...f, confirm: e.target.value }))} /> {pwError &&

{pwError}

}
{ 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" />
{/* Garmin Connect Sync */}

Connect your Garmin account to automatically import new activities and wellness data every hour. Credentials are encrypted at rest.

{garminConfig?.connected && (
✓ Connected as {garminConfig.email}
{garminConfig.last_sync_at && ( Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} )} {garminConfig.last_sync_status && ( {garminConfig.last_sync_status} )}
)}
setGcForm(f => ({ ...f, email: e.target.value }))} /> setGcForm(f => ({ ...f, password: e.target.value }))} />
{[ ['sync_enabled', 'Enable hourly sync'], ['sync_activities', 'Sync activities (FIT download)'], ['sync_wellness', 'Sync wellness data'], ].map(([key, label]) => ( ))}
setGcForm(f => ({ ...f, sync_lookback_days: parseInt(e.target.value, 10) || 30 }))} /> {gcForm.sync_lookback_days > 365 && gcForm.sync_lookback_days !== -1 && (

Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.

)}
{gcError &&

{gcError}

}
{ 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 && ( <> )}
{gcSyncing && (

{garminConfig?.last_sync_status || 'Starting sync…'}

)}
{/* PocketID — admin only */} {user?.is_admin && (

Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.

setPidForm(f => ({ ...f, issuer: e.target.value }))} /> setPidForm(f => ({ ...f, client_id: e.target.value }))} /> setPidForm(f => ({ ...f, client_secret: e.target.value }))} /> {pocketidConfig?.enabled && (

✓ PocketID is currently active

)}
savePocketID.mutate(pidForm)} loading={savePocketID.isPending} saved={pidSaved} label="Save PocketID config" />
)}
) }