From 600ecc668532ac45e66d8d470d5bf0fffeabce90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 23 May 2026 18:48:14 +0200 Subject: [PATCH 1/3] Match Komoot import page layout to Wahoo import page - Switch to max-w-4xl to match the generic import page - Move the trigger button to the header row (title on left, action on right) - Show progress inline as "X / Y" in the header while running - Progress card only appears once a batch exists; empty state is a centered paragraph like the generic page's "no workouts" message - Link to /activities on completion instead of a separate paragraph Co-Authored-By: Claude Sonnet 4.6 --- .../journal/app/routes/sync.import.komoot.tsx | 177 +++++++++--------- 1 file changed, 89 insertions(+), 88 deletions(-) diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx index 07ab181..b8b51f7 100644 --- a/apps/journal/app/routes/sync.import.komoot.tsx +++ b/apps/journal/app/routes/sync.import.komoot.tsx @@ -50,8 +50,6 @@ 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") ?? "" } }, @@ -78,107 +76,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")}

)}
); From 6a441675b9e9ae6ad379a438c081e6b5496fd956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 23 May 2026 18:55:18 +0200 Subject: [PATCH 2/3] Fix import action crashing in dev when pg-boss is not initialized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action was doing an internal self-fetch to /api/sync/komoot/import, which returned plain text on error (boss not initialized) — the JSON.parse then threw "Unexpected token U". Fix: inline the action logic directly in the route and fall back to a fire-and-forget runKomootBulkImport call when pg-boss is unavailable (i.e. dev mode with Vite, not the custom server). Co-Authored-By: Claude Sonnet 4.6 --- .../journal/app/routes/sync.import.komoot.tsx | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx index b8b51f7..2512942 100644 --- a/apps/journal/app/routes/sync.import.komoot.tsx +++ b/apps/journal/app/routes/sync.import.komoot.tsx @@ -50,14 +50,41 @@ export async function action({ request }: Route.ActionArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); - 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", + }); + + // Try to enqueue via pg-boss (available in production / custom server). + // In dev (Vite), boss is never initialized — fall back to running inline + // in a detached promise so the response is still immediate. + try { + const { getBoss } = await import("~/lib/boss.server"); + const boss = getBoss(); + await boss.send("komoot-bulk-import", { + batchId, + userId: user.id, + creds: service.credentials, + }); + } catch { + const { runKomootBulkImport } = await import("~/lib/komoot-bulk-import.server"); + const creds = service.credentials as Parameters[2]; + // Fire-and-forget — don't await so the redirect happens immediately + void runKomootBulkImport(batchId, user.id, creds).catch(() => {}); } + return redirect("/sync/import/komoot"); } From 3af63c677f701b1d28c06b83d8fa8e38c9f506f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 23 May 2026 18:57:00 +0200 Subject: [PATCH 3/3] Fix bulk import stuck in pending state in dev mode In dev (Vite), runKomootBulkImport was fire-and-forgotten with .catch(()=>{}) which silently swallowed all errors, leaving the batch frozen at "pending". Fix: - Pre-mark the batch as "running" before firing the detached promise so the UI immediately transitions out of the pending state - On failure, update the batch to "failed" with the error message instead of swallowing it, so the UI shows the error and offers a retry Co-Authored-By: Claude Sonnet 4.6 --- .../journal/app/routes/sync.import.komoot.tsx | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx index 2512942..260c3dd 100644 --- a/apps/journal/app/routes/sync.import.komoot.tsx +++ b/apps/journal/app/routes/sync.import.komoot.tsx @@ -67,22 +67,35 @@ export async function action({ request }: Route.ActionArgs) { status: "pending", }); - // Try to enqueue via pg-boss (available in production / custom server). - // In dev (Vite), boss is never initialized — fall back to running inline - // in a detached promise so the response is still immediate. + 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: service.credentials, - }); + await boss.send("komoot-bulk-import", { batchId, userId: user.id, creds }); + enqueued = true; } catch { - const { runKomootBulkImport } = await import("~/lib/komoot-bulk-import.server"); - const creds = service.credentials as Parameters[2]; - // Fire-and-forget — don't await so the redirect happens immediately - void runKomootBulkImport(batchId, user.id, creds).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");