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(null); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [syncStatus, setSyncStatus] = useState(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; 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); } } async function sync() { setBusy(true); setError(null); try { 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(); refreshStatus(); } catch (e) { setError(String(e)); } finally { setBusy(false); } } const status = auth?.status ?? "unknown"; return (
{status === "authenticated" && "Connected to Garmin"} {status === "mfa_required" && "MFA code required"} {status === "failed" && "Connection failed"} {status === "unknown" && "Not connected to Garmin"} {status !== "authenticated" && status !== "mfa_required" && ( )} {status === "authenticated" && ( <> {syncStatus?.in_progress && ( {syncProgressLabel(syncStatus)} )} {syncStatus?.last_run && !syncStatus.in_progress && ( 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`} )} )}
{status === "mfa_required" && (
setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitMFA()} />
)} {auth?.message &&

{auth.message}

} {error &&

{error}

}
); }