diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx index 07ab181..260c3dd 100644 --- a/apps/journal/app/routes/sync.import.komoot.tsx +++ b/apps/journal/app/routes/sync.import.komoot.tsx @@ -50,16 +50,54 @@ export async function action({ request }: Route.ActionArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); - // Delegate to the API route — just redirect so the page reloads with - // the new batch after the POST. - const resp = await fetch( - new URL("/api/sync/komoot/import", new URL(request.url).origin), - { method: "POST", headers: { cookie: request.headers.get("cookie") ?? "" } }, - ); - if (!resp.ok) { - const body = (await resp.json()) as { error?: string }; - return data({ error: body.error ?? "failed" }, { status: resp.status }); + const { randomUUID } = await import("node:crypto"); + const { getDb } = await import("~/lib/db"); + const { importBatches } = await import("@trails-cool/db/schema/journal"); + + const service = await getService(user.id, "komoot"); + if (!service) return redirect("/settings/connections/komoot"); + + const db = getDb(); + const batchId = randomUUID(); + await db.insert(importBatches).values({ + id: batchId, + userId: user.id, + connectionId: service.id, + provider: "komoot", + status: "pending", + }); + + const { runKomootBulkImport } = await import("~/lib/komoot-bulk-import.server"); + const creds = service.credentials as Parameters[2]; + + // Try to enqueue via pg-boss (production / custom server.ts). + // In dev (Vite), boss is never initialized — fall back to an inline + // fire-and-forget so the redirect still happens immediately. + let enqueued = false; + try { + const { getBoss } = await import("~/lib/boss.server"); + const boss = getBoss(); + await boss.send("komoot-bulk-import", { batchId, userId: user.id, creds }); + enqueued = true; + } catch { + // pg-boss not available } + + if (!enqueued) { + // Mark the batch running before returning so the UI doesn't stay frozen + // if the detached promise fails to start. + const { eq } = await import("drizzle-orm"); + await db.update(importBatches).set({ status: "running" }).where(eq(importBatches.id, batchId)); + void runKomootBulkImport(batchId, user.id, creds).catch(async (err) => { + const { eq: eq2 } = await import("drizzle-orm"); + await db.update(importBatches).set({ + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err), + completedAt: new Date(), + }).where(eq2(importBatches.id, batchId)); + }); + } + return redirect("/sync/import/komoot"); } @@ -78,107 +116,110 @@ export default function KomootImportPage({ loaderData }: Route.ComponentProps) { const pollingRef = useRef | null>(null); const isActive = batch?.status === "pending" || batch?.status === "running"; + const isIdle = triggerFetcher.state === "idle"; useEffect(() => { if (isActive) { - pollingRef.current = setInterval(() => { - revalidator.revalidate(); - }, 2000); + pollingRef.current = setInterval(() => revalidator.revalidate(), 2000); } - return () => { - if (pollingRef.current) clearInterval(pollingRef.current); - }; + return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; }, [isActive]); const elapsedSeconds = batch ? Math.floor( - (batch.completedAt - ? new Date(batch.completedAt).getTime() - : Date.now()) - new Date(batch.startedAt).getTime(), - ) / 1000 + ((batch.completedAt ? new Date(batch.completedAt) : new Date()).getTime() + - new Date(batch.startedAt).getTime()) / 1000, + ) : 0; return ( -
-

- {t("sync.importFrom", { provider: "Komoot" })} -

+
+ {/* Header row — mirrors sync.import.$provider layout */} +
+

+ {t("sync.importFrom", { provider: "Komoot" })} +

-
- {!batch && ( -
-

{t("komoot.import.noImportYet")}

- - - -
- )} - - {batch && ( -
-
- - {(batch.status === "completed" || batch.status === "failed") && ( - - - - )} -
- - {isActive && ( -
-
0 - ? `${Math.round(((batch.importedCount + batch.duplicateCount) / batch.totalFound) * 100)}%` - : "5%", - }} - /> -
- )} - -
- - - -
- - {batch.status === "completed" && ( -

- {t("komoot.import.completedIn", { duration: formatDuration(elapsedSeconds) })} -

- )} - - {batch.status === "failed" && batch.errorMessage && ( -

- {batch.errorMessage} -

- )} -
+ {isActive ? ( + + {batch.totalFound > 0 + ? t("sync.importingProgress", { + current: batch.importedCount + batch.duplicateCount, + total: batch.totalFound, + }) + : t("komoot.import.status.running")} + + ) : ( + + + )}
- {batch?.status === "completed" && ( -

- - {t("komoot.import.viewActivities")} - -

+ {/* Progress card — shown once a batch exists */} + {batch && ( +
+
+ + {(batch.status === "completed" || batch.status === "failed") && ( + + + + )} +
+ + {isActive && ( +
+
0 + ? `${Math.round(((batch.importedCount + batch.duplicateCount) / batch.totalFound) * 100)}%` + : "5%", + }} + /> +
+ )} + +
+ + + +
+ + {batch.status === "completed" && ( +

+ {t("komoot.import.completedIn", { duration: formatDuration(elapsedSeconds) })} + {" · "} + + {t("komoot.import.viewActivities")} + +

+ )} + + {batch.status === "failed" && batch.errorMessage && ( +

+ {batch.errorMessage} +

+ )} +
+ )} + + {/* Empty state — no batch yet */} + {!batch && ( +

{t("komoot.import.noImportYet")}

)}
);