// Komoot-specific import page. Mirrors the generic sync.import.$provider page // but fetches GPX directly from Komoot instead of downloading a FIT file. import { useCallback, useEffect, useRef, useState } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.komoot"; import { getSessionUser } from "~/lib/auth/session.server"; import { getService, capabilityContextFor } from "~/lib/connected-services"; import { getManifest } from "~/lib/connected-services"; import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; import { createActivity } from "~/lib/activities.server"; import { fetchKomootTourGpx } from "~/lib/komoot.server"; import { decrypt } from "~/lib/crypto.server"; import { ClientDate } from "~/components/ClientDate"; type KomootCreds = | { mode: "public"; komootUserId: string } | { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string }; function getBasicAuthToken(creds: KomootCreds): string | undefined { if (creds.mode !== "authenticated") return undefined; const password = decrypt(creds.encryptedPassword); return Buffer.from(`${creds.email}:${password}`).toString("base64"); } export function meta() { return [{ title: "Import from Komoot — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); const manifest = getManifest("komoot"); if (!manifest?.importer) throw data({ error: "Komoot not configured" }, { status: 500 }); const service = await getService(user.id, "komoot"); if (!service) return redirect("/settings/connections/komoot"); const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? "1"); const ctx = capabilityContextFor(service.id); const workoutList = await manifest.importer.listImportable(ctx, page); const importedIds = await getImportedIds( user.id, "komoot", workoutList.workouts.map((w) => w.id), ); return data({ workouts: workoutList.workouts.map((w) => ({ ...w, imported: importedIds.has(w.id), })), page: workoutList.page, totalPages: Math.ceil(workoutList.total / workoutList.perPage), }); } export async function action({ request }: Route.ActionArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); const service = await getService(user.id, "komoot"); if (!service) return redirect("/settings/connections/komoot"); const formData = await request.formData(); const tourId = formData.get("workoutId") as string; const workoutName = formData.get("workoutName") as string; const startedAt = formData.get("startedAt") as string; const distance = formData.get("distance") as string; const duration = formData.get("duration") as string; const creds = service.credentials as KomootCreds; const basicAuthToken = getBasicAuthToken(creds); let gpx: string | undefined; try { gpx = await fetchKomootTourGpx(tourId, basicAuthToken); } catch { // No GPX available — import without geometry } const activityId = await createActivity(user.id, { name: workoutName || `Komoot tour ${tourId}`, gpx, distance: distance ? parseFloat(distance) : null, duration: duration ? parseInt(duration) : null, startedAt: startedAt ? new Date(startedAt) : null, }); await recordImport(user.id, "komoot", tourId, activityId); return data({ imported: tourId }); } function TourRow({ workout, alreadyImported, }: { workout: { id: string; name: string; startedAt: string; distance: number | null; duration: number | null }; alreadyImported: boolean; }) { const { t } = useTranslation("journal"); const fetcher = useFetcher<{ imported?: string }>(); const isImporting = fetcher.state !== "idle"; const isImported = alreadyImported || fetcher.data?.imported === workout.id; return (
  • {workout.name}

    {workout.startedAt && } {workout.distance != null && {(workout.distance / 1000).toFixed(1)} km} {workout.duration != null && {Math.round(workout.duration / 60)} min}
    {isImported ? ( {t("sync.imported")} ) : isImporting ? ( {t("sync.importing")} ) : ( )}
  • ); } function tourFormData(w: { id: string; name: string; startedAt: string; distance: number | null; duration: number | null }) { const fd = new FormData(); fd.set("workoutId", w.id); fd.set("workoutName", w.name); fd.set("startedAt", w.startedAt ?? ""); fd.set("distance", w.distance != null ? String(w.distance) : ""); fd.set("duration", w.duration != null ? String(w.duration) : ""); return fd; } export default function KomootImportPage({ loaderData }: Route.ComponentProps) { const { workouts, page, totalPages } = loaderData; const { t } = useTranslation("journal"); const importableWorkouts = workouts.filter((w) => !w.imported); const [importAllActive, setImportAllActive] = useState(false); const [importAllIndex, setImportAllIndex] = useState(0); const importAllFetcher = useFetcher<{ imported?: string }>(); const importAllRef = useRef({ index: 0, workouts: importableWorkouts }); useEffect(() => { importAllRef.current.workouts = importableWorkouts; }, [importableWorkouts]); useEffect(() => { if (!importAllActive) return; if (importAllFetcher.state !== "idle") return; const nextIndex = importAllFetcher.data ? importAllRef.current.index + 1 : importAllRef.current.index; importAllRef.current.index = nextIndex; setImportAllIndex(nextIndex); if (nextIndex >= importAllRef.current.workouts.length) { setImportAllActive(false); return; } const w = importAllRef.current.workouts[nextIndex]!; importAllFetcher.submit(tourFormData(w), { method: "post" }); }, [importAllActive, importAllFetcher.state, importAllFetcher.data]); const startImportAll = useCallback(() => { if (importableWorkouts.length === 0) return; importAllRef.current = { index: 0, workouts: importableWorkouts }; setImportAllIndex(0); setImportAllActive(true); importAllFetcher.submit(tourFormData(importableWorkouts[0]!), { method: "post" }); }, [importableWorkouts, importAllFetcher]); return (

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

    {importableWorkouts.length > 0 && ( importAllActive ? ( {t("sync.importingProgress", { current: importAllIndex + 1, total: importAllRef.current.workouts.length, })} ) : ( ) )}
    {workouts.length === 0 ? (

    {t("sync.noWorkouts")}

    ) : ( )} {totalPages > 1 && (
    {page > 1 && ( {t("sync.previous")} )} {page} / {totalPages} {page < totalPages && ( {t("sync.next")} )}
    )}
    ); }