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 (
{label}
{children}
{hint &&
{hint}
}
)
}
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
return (
)
}
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
return (
{loading ? 'Saving…' : label}
{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 */}
{
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, [key]: e.target.checked }))}
className="w-4 h-4 accent-blue-500" />
{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 ? 'Syncing…' : '↻ Sync now'}
{ if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
className="text-red-400 hover:text-red-300 text-sm transition-colors">
Disconnect
>
)}
{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.
savePocketID.mutate(pidForm)}
loading={savePocketID.isPending}
saved={pidSaved}
label="Save PocketID config"
/>
)}
)
}