Initial commit: smartrun MVP

Garmin run classification and progression tracker. Go backend (MCP
client to mcp-garmin, SQLite store, deterministic rule engine, REST
API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:33:06 +02:00
commit f689f74ae0
69 changed files with 10666 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
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>
);
}