144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function sync(kind: "run" | "backfill") {
|
||
|
|
setBusy(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
await (kind === "run" ? api.syncRun() : api.syncBackfill());
|
||
|
|
refreshStatus();
|
||
|
|
} catch (e) {
|
||
|
|
setError(String(e));
|
||
|
|
} finally {
|
||
|
|
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" && (
|
||
|
|
<>
|
||
|
|
<button disabled={busy} onClick={() => sync("run")}>
|
||
|
|
Sync now
|
||
|
|
</button>
|
||
|
|
<button disabled={busy} onClick={() => sync("backfill")}>
|
||
|
|
Full backfill
|
||
|
|
</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>
|
||
|
|
);
|
||
|
|
}
|