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({ onBeforeConnect }: { onBeforeConnect?: () => Promise }) { const [auth, setAuth] = useState(null); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [syncStatus, setSyncStatus] = useState(null); // There's no real Garmin logout -- the backend session just sits idle. // "Disconnect" only hides that and shows Connect again locally; a real // login (connect()) clears it. const [disconnected, setDisconnected] = useState(false); 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 { await onBeforeConnect?.(); setAuth(await api.login()); setDisconnected(false); } catch (e) { setError(String(e)); } finally { setBusy(false); } } function disconnect() { setDisconnected(true); } 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(); // Reset runs as a background sync job; wait for it to actually finish // before reloading, otherwise other pages (e.g. Activities) 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(); } catch (e) { setError(String(e)); setBusy(false); } } const status = disconnected ? "unknown" : auth?.status ?? "unknown"; return (
{status !== "authenticated" && status !== "mfa_required" && ( )} {status === "authenticated" && ( <> )} {/* Reset all only wipes local DB state (see handleSyncReset) -- it doesn't touch the Garmin session, so it stays available even while disconnected/not-yet-connected. */}
{/* Connected/not-connected is already conveyed by the Connect/Disconnect button itself -- only MFA/failed need a label, since no button distinguishes those from "not connected". */} {(status === "mfa_required" || status === "failed") && ( {status === "mfa_required" ? "MFA code required" : "Connection failed"} )}
{status === "mfa_required" && (
setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitMFA()} />
)} {!disconnected && auth?.message &&

{auth.message}

} {/* Full-width so a long "last sync"/pending-details message has room to breathe, instead of being squeezed next to the status dot. */} {status === "authenticated" && syncStatus?.in_progress && (

{syncProgressLabel(syncStatus)}

)} {status === "authenticated" && 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`}

)} {error &&

{error}

}
); }