2026-07-17 18:33:06 +02:00
import { useEffect , useRef , useState } from "react" ;
import { api } from "../api/client" ;
import type { AuthResponse , SyncStatus } from "../types/api" ;
function syncProgressLabel ( status : SyncStatus ) : string {
const { detail_fill_progress : fill , activities_pending_details : pending } = status ;
if ( fill . Total > 0 ) {
// pending includes the current batch, so subtract what's already
// counted in this batch's Total to avoid double-counting the "beyond
// this batch" remainder.
const beyondBatch = Math . max ( 0 , pending - ( fill . Total - fill . Done ) ) ;
return ` syncing: ${ fill . Done } / ${ fill . Total } activities ${ beyondBatch > 0 ? ` (+ ${ beyondBatch } more queued) ` : "" } ` ;
}
return "syncing..." ;
}
export function GarminConnection() {
const [ auth , setAuth ] = useState < AuthResponse | null > ( null ) ;
const [ code , setCode ] = useState ( "" ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ syncStatus , setSyncStatus ] = useState < SyncStatus | null > ( null ) ;
function refreshStatus() {
api . authStatus ( ) . then ( setAuth ) . catch ( ( e ) = > setError ( String ( e ) ) ) ;
api . syncStatus ( ) . then ( setSyncStatus ) . catch ( ( ) = > { } ) ;
}
// Poll faster while a sync is actually running, so progress feels live;
// back off to a relaxed interval the rest of the time.
const syncStatusRef = useRef ( syncStatus ) ;
syncStatusRef . current = syncStatus ;
useEffect ( ( ) = > {
refreshStatus ( ) ;
let timeout : ReturnType < typeof setTimeout > ;
const tick = ( ) = > {
refreshStatus ( ) ;
timeout = setTimeout ( tick , syncStatusRef . current ? . in_progress ? 1500 : 6000 ) ;
} ;
timeout = setTimeout ( tick , 1500 ) ;
return ( ) = > clearTimeout ( timeout ) ;
} , [ ] ) ;
async function connect() {
setBusy ( true ) ;
setError ( null ) ;
try {
setAuth ( await api . login ( ) ) ;
} catch ( e ) {
setError ( String ( e ) ) ;
} finally {
setBusy ( false ) ;
}
}
async function submitMFA() {
if ( ! code . trim ( ) ) return ;
setBusy ( true ) ;
setError ( null ) ;
try {
setAuth ( await api . submitMFA ( code . trim ( ) ) ) ;
setCode ( "" ) ;
} catch ( e ) {
setError ( String ( e ) ) ;
} finally {
setBusy ( false ) ;
}
}
2026-07-19 12:41:38 +02:00
async function sync() {
2026-07-17 18:33:06 +02:00
setBusy ( true ) ;
setError ( null ) ;
try {
2026-07-19 12:41:38 +02:00
await api . syncRun ( ) ;
refreshStatus ( ) ;
} catch ( e ) {
setError ( String ( e ) ) ;
} finally {
setBusy ( false ) ;
}
}
async function resetAll() {
if ( ! window . confirm ( "This deletes every synced activity, lap, and kind assignment, then starts a fresh pull from Garmin next sync. This cannot be undone. Continue?" ) ) {
return ;
}
setBusy ( true ) ;
setError ( null ) ;
try {
await api . resetSync ( ) ;
2026-07-19 12:45:03 +02:00
// Reset runs as a background sync job; wait for it to actually finish
// before reloading, otherwise other pages (e.g. Review Queue) would
// still show the just-deleted activities from their own stale state.
let status = await api . syncStatus ( ) ;
while ( status . in_progress ) {
await new Promise ( ( resolve ) = > setTimeout ( resolve , 300 ) ) ;
status = await api . syncStatus ( ) ;
}
window . location . reload ( ) ;
2026-07-17 18:33:06 +02:00
} catch ( e ) {
setError ( String ( e ) ) ;
setBusy ( false ) ;
}
}
const status = auth ? . status ? ? "unknown" ;
return (
< div className = "garmin-connection" >
< div className = "garmin-connection-row" >
< span className = { ` status-dot status- ${ status } ` } / >
< span className = "status-label" >
{ status === "authenticated" && "Connected to Garmin" }
{ status === "mfa_required" && "MFA code required" }
{ status === "failed" && "Connection failed" }
{ status === "unknown" && "Not connected to Garmin" }
< / span >
{ status !== "authenticated" && status !== "mfa_required" && (
< button disabled = { busy } onClick = { connect } >
Connect to Garmin
< / button >
) }
{ status === "authenticated" && (
< >
2026-07-19 12:41:38 +02:00
< button disabled = { busy } onClick = { sync } >
2026-07-17 18:33:06 +02:00
Sync now
< / button >
2026-07-19 12:41:38 +02:00
< button className = "button-danger" disabled = { busy } onClick = { resetAll } >
Reset all
2026-07-17 18:33:06 +02:00
< / button >
{ syncStatus ? . in_progress && (
< span className = "status-label" > { syncProgressLabel ( syncStatus ) } < / span >
) }
{ syncStatus ? . last_run && ! syncStatus . in_progress && (
< span className = "status-label" >
last sync : { syncStatus . last_run . Status } ( { syncStatus . last_run . ActivitiesFetched } activities )
{ syncStatus . activities_pending_details > 0 &&
` — ${ syncStatus . activities_pending_details } still need details, click Sync now again ` }
< / span >
) }
< / >
) }
< / div >
{ status === "mfa_required" && (
< div className = "garmin-connection-row" >
< input
placeholder = "MFA code"
value = { code }
onChange = { ( e ) = > setCode ( e . target . value ) }
onKeyDown = { ( e ) = > e . key === "Enter" && submitMFA ( ) }
/ >
< button disabled = { busy } onClick = { submitMFA } >
Submit code
< / button >
< / div >
) }
{ auth ? . message && < p className = "garmin-connection-message" > { auth . message } < / p > }
{ error && < p className = "error" > { error } < / p > }
< / div >
) ;
}