@@ -1,4 +1,4 @@
import { useState , useEffect } 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,13 +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 || '' ,
} )
@@ -70,23 +88,16 @@ export default function ProfilePage() {
const updateProfile = useMutation ( {
mutationFn : data => api . patch ( '/profile/' , data ) . then ( r => r . data ) ,
onSuccess : ( ) => { qc . invalidateQueries ( { queryKey : [ 'profile' ] } ) ; setHrSaved ( true ) ; setTimeout ( ( ) => setHrSaved ( false ) , 3000 ) } ,
} )
// 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' ] } ) ,
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
@@ -99,6 +110,99 @@ export default function ProfilePage() {
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 )
const gcFormLoaded = useRef ( false )
useEffect ( ( ) => ( ) => { if ( syncPollRef . current ) clearInterval ( syncPollRef . current ) } , [ ] )
useEffect ( ( ) => {
if ( garminConfig ? . connected && ! gcFormLoaded . current ) {
gcFormLoaded . current = true
setGcForm ( f => ( {
... f ,
email : garminConfig . email || '' ,
sync _enabled : garminConfig . sync _enabled ,
sync _activities : garminConfig . sync _activities ,
sync _wellness : garminConfig . sync _wellness ,
sync _lookback _days : String ( garminConfig . sync _lookback _days ? ? 30 ) ,
} ) )
} else if ( ! garminConfig ? . connected ) {
gcFormLoaded . current = false
}
} , [ 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 3s: 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 )
}
} , 3000 )
// Absolute safety: stop polling after 4 hours but keep bar visible — sync may still be running
setTimeout ( ( ) => {
if ( syncPollRef . current ) { clearInterval ( syncPollRef . current ) ; syncPollRef . current = null }
} , 4 * 60 * 60 * 1000 )
} catch {
setGcSyncing ( false )
}
}
const syncProgressPct = status => {
if ( ! status ) return 3
if ( status . startsWith ( 'Connecting' ) ) return 10
if ( status . startsWith ( 'Syncing activities' ) ) {
const m = status . match ( /(\d+)\/(\d+)/ )
if ( m ) {
const done = parseInt ( m [ 1 ] , 10 ) , total = parseInt ( m [ 2 ] , 10 )
if ( total > 0 ) return 15 + Math . round ( done / total * 30 )
}
return 20
}
if ( status . startsWith ( 'Syncing wellness' ) ) {
const m = status . match ( /(\d+)\/(\d+)/ )
if ( m ) {
const done = parseInt ( m [ 1 ] , 10 ) , total = parseInt ( m [ 2 ] , 10 )
if ( total > 0 ) return 45 + Math . round ( done / total * 45 )
}
return 50
}
return 3
}
// PocketID config
const [ pidForm , setPidForm ] = useState ( { issuer : '' , client _id : '' , client _secret : '' } )
const [ pidSaved , setPidSaved ] = useState ( false )
@@ -134,10 +238,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 } ) ) } / >
@@ -148,57 +248,36 @@ 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 = { ( ) => updateProfile . mutate ( Object . fromEntries (
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 }
/ >
< / 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 >
{ hrZoneRecalc && (
< p className = "text-xs text-blue-400 mt-1" > HR zones are being recalculated for your existing activities . < / p >
) }
< / Section >
@@ -230,6 +309,133 @@ export default function ProfilePage() {
/ >
< / 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 : e . target . value } ) ) } / >
{ ( ( ) => { const n = parseInt ( gcForm . sync _lookback _days , 10 ) ; return n > 365 && n !== - 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 ,
sync _lookback _days : parseInt ( gcForm . sync _lookback _days , 10 ) || 30 ,
}
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 && ( ( ) => {
const status = garminConfig ? . last _sync _status || ''
const pct = syncProgressPct ( status )
const phase = status . startsWith ( 'Connecting' ) ? 0
: status . startsWith ( 'Syncing activities' ) ? 1
: status . startsWith ( 'Syncing wellness' ) ? 2
: status . startsWith ( 'OK' ) || status . startsWith ( 'Partial' ) ? 3 : - 1
return (
< div className = "space-y-2 pt-1" >
< div className = "flex items-center gap-1 text-xs" >
{ [ [ 'Connect' , 0 ] , [ 'Activities' , 1 ] , [ 'Wellness' , 2 ] ] . map ( ( [ label , idx ] ) => (
< span key = { label } className = { ` flex items-center gap-1 ${ phase >= idx ? 'text-blue-400' : 'text-gray-600' } ` } >
{ idx > 0 && < span className = "text-gray-700" > › < / span > }
{ label }
< / span >
) ) }
< / div >
< div className = "h-2 bg-gray-800 rounded-full overflow-hidden" >
< div
className = "h-full bg-blue-500 rounded-full transition-all duration-700"
style = { { width : ` ${ pct } % ` } }
/ >
< / div >
< p className = "text-xs text-blue-400" >
{ status || 'Starting sync…' }
< / p >
< / div >
)
} ) ( ) }
< / Section >
{ /* PocketID — admin only */ }
{ user ? . is _admin && (
< Section title = "🔑 PocketID Passkey Authentication (Admin)" >