Garmin Connect as the third connected-services provider (spec: garmin-import). The interesting parts: - Push-first ingestion: Garmin has no list endpoint. The webhook normalizes ping (callbackURL) and push (inline) notification batches into events; the slow work (authorized FIT download, FIT→GPX via the shared converter, activity creation) runs in a garmin-import-activity pg-boss job so the webhook answers fast. Callback URLs are validated against Garmin's API host before any fetch (SSRF guard). - History via backfill requests: /sync/import/garmin is a date-range requester with honest async progress (no pick list — the concept doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap; overlaps are free via sync_imports dedupe. Requests persist in import_batches via two new nullable columns (range_start/range_end). - OAuth2 + PKCE on the existing oauth credential kind. Design correction from apply: the verifier rides a short-lived httpOnly cookie scoped to the callback path — the state param is visible in redirect URLs and must never carry it. Manifests opt in via pkce:true. - Deregistration notifications flip the connection to 'revoked' (row kept for audit, imports retained, re-connect prompt shown). - Framework evolutions, all additive: parseWebhook returns WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains configured()/importUrl/pkce, importActivity accepts summary stats for FIT-less imports, manager gains markRevoked. - Env-gated: no GARMIN_CLIENT_ID → provider hidden on /settings/connections. Privacy manifest entry (DE+EN). i18n en+de. Rollout (§6) stays gated on the Garmin Developer Program application (submitted 2026-06-07). Fixtures are doc-shaped; the staging soak swaps in recorded payloads if shapes differ. Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known flakes green isolated ✓ openspec validate ✓ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
import { data, Form, Link, useNavigation } from "react-router";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { Route } from "./+types/sync.import.garmin";
|
||
import { ClientDate } from "~/components/ClientDate";
|
||
|
||
export function meta() {
|
||
return [{ title: "Garmin import — trails.cool" }];
|
||
}
|
||
|
||
export async function loader({ request }: Route.LoaderArgs) {
|
||
const { loadGarminImportPage } = await import("./sync.import.garmin.server");
|
||
return data(await loadGarminImportPage(request));
|
||
}
|
||
|
||
export async function action({ request }: Route.ActionArgs) {
|
||
const { handleGarminBackfillAction } = await import("./sync.import.garmin.server");
|
||
return data(await handleGarminBackfillAction(request));
|
||
}
|
||
|
||
export default function GarminImport({ loaderData, actionData }: Route.ComponentProps) {
|
||
const { t } = useTranslation(["journal"]);
|
||
const navigation = useNavigation();
|
||
const busy = navigation.state !== "idle";
|
||
|
||
if (!loaderData.connected) {
|
||
return (
|
||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||
<h1 className="text-2xl font-bold text-gray-900">{t("sync.garmin.title")}</h1>
|
||
<p className="mt-4 text-sm text-gray-600">{t("sync.garmin.notConnected")}</p>
|
||
<Link to="/settings/connections" className="mt-2 inline-block text-sm text-blue-600 hover:underline">
|
||
{t("sync.garmin.goConnect")}
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||
<h1 className="text-2xl font-bold text-gray-900">{t("sync.garmin.title")}</h1>
|
||
<p className="mt-2 text-sm text-gray-600">{t("sync.garmin.subtitle")}</p>
|
||
|
||
{loaderData.status !== "active" && (
|
||
<div role="alert" className="mt-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||
{t("sync.garmin.needsRelink")}
|
||
</div>
|
||
)}
|
||
|
||
<Form method="post" className="mt-6 flex flex-wrap items-end gap-3">
|
||
<label className="block text-sm">
|
||
<span className="text-gray-700">{t("sync.garmin.from")}</span>
|
||
<input
|
||
type="date"
|
||
name="from"
|
||
required
|
||
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||
/>
|
||
</label>
|
||
<label className="block text-sm">
|
||
<span className="text-gray-700">{t("sync.garmin.to")}</span>
|
||
<input
|
||
type="date"
|
||
name="to"
|
||
required
|
||
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="submit"
|
||
disabled={busy || loaderData.status !== "active"}
|
||
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{busy ? t("sync.garmin.requesting") : t("sync.garmin.request")}
|
||
</button>
|
||
</Form>
|
||
|
||
{actionData && "ok" in actionData && actionData.ok && (
|
||
<p className="mt-3 text-sm text-green-700">{t("sync.garmin.requested")}</p>
|
||
)}
|
||
{actionData && "ok" in actionData && !actionData.ok && (
|
||
<p role="alert" className="mt-3 text-sm text-red-700">
|
||
{t(`sync.garmin.errors.${actionData.error}`)}
|
||
</p>
|
||
)}
|
||
|
||
<p className="mt-6 text-xs text-gray-500">{t("sync.garmin.asyncNote")}</p>
|
||
|
||
{loaderData.batches.length > 0 && (
|
||
<ul className="mt-4 space-y-2">
|
||
{loaderData.batches.map((b) => (
|
||
<li
|
||
key={b.id}
|
||
className="flex items-center justify-between rounded-md border border-gray-200 px-4 py-3 text-sm"
|
||
>
|
||
<span className="text-gray-900">
|
||
{b.rangeStart && b.rangeEnd ? (
|
||
<>
|
||
<ClientDate iso={b.rangeStart} /> – <ClientDate iso={b.rangeEnd} />
|
||
</>
|
||
) : (
|
||
<ClientDate iso={b.requestedAt} />
|
||
)}
|
||
</span>
|
||
<span className="text-gray-500">
|
||
{t("sync.garmin.importedSince", { count: b.importedSince })}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|