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.$provider"; import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest, getService, capabilityContextFor, } from "~/lib/connected-services"; import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; import { createActivity } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; export async function loader({ params, request }: Route.LoaderArgs) { const user = await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest || !manifest.importer) { throw data({ error: "Unknown provider" }, { status: 404 }); } const service = await getService(user.id, manifest.id); if (!service) return redirect("/settings"); 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, manifest.id, workoutList.workouts.map((w) => w.id), ); return data({ provider: { id: manifest.id, name: manifest.displayName }, 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({ params, request }: Route.ActionArgs) { const user = await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest || !manifest.importer) { throw data({ error: "Unknown provider" }, { status: 404 }); } const service = await getService(user.id, manifest.id); if (!service) return redirect("/settings"); const formData = await request.formData(); const workoutId = formData.get("workoutId") as string; const workoutName = formData.get("workoutName") as string; const fileUrl = formData.get("fileUrl") as string; const startedAt = formData.get("startedAt") as string; const distance = formData.get("distance") as string; const duration = formData.get("duration") as string; // Inline import here (not via Importer.importOne) so we can pass through // the form-supplied metadata (started_at, distance, duration) the // capability seam doesn't carry. The seam-shape importer is reserved for // automatic / webhook-driven imports. let gpx: string | null = null; if (fileUrl) { const ctx = capabilityContextFor(service.id); // Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials // so the manager handles refresh of any near-expired token. const buffer = await ctx.withFreshCredentials(async () => { const resp = await fetch(fileUrl); if (!resp.ok) throw new Error(`Download failed: ${resp.status}`); return Buffer.from(await resp.arrayBuffer()); }); // Lazy-load to avoid bundling fit-file-parser into all routes. const { default: FitParser } = await import("fit-file-parser"); const { generateGpx } = await import("@trails-cool/gpx"); const parsed = await new Promise>((resolve, reject) => { const parser = new FitParser({ force: true }); // eslint-disable-next-line @typescript-eslint/no-explicit-any parser.parse(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {}))); }); const records = (parsed.records ?? []) as Array<{ position_lat?: number; position_long?: number; altitude?: number; timestamp?: string | Date; }>; const trackPoints = records .filter((r) => r.position_lat != null && r.position_long != null) .map((r) => ({ lat: r.position_lat!, lon: r.position_long!, ele: r.altitude, time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, })); if (trackPoints.length >= 2) { gpx = generateGpx({ name: workoutName, tracks: [trackPoints] }); } } const activityId = await createActivity(user.id, { name: workoutName || `${manifest.displayName} workout`, gpx: gpx ?? undefined, distance: distance ? parseFloat(distance) : null, duration: duration ? parseInt(duration) : null, startedAt: startedAt ? new Date(startedAt) : null, }); await recordImport(user.id, manifest.id, workoutId, activityId); return data({ imported: workoutId }); } export function meta({ data: loaderData }: Route.MetaArgs) { const name = (loaderData as { provider: { name: string } })?.provider?.name ?? "Import"; return [{ title: `Import from ${name} — trails.cool` }]; } function WorkoutRow({ workout, provider, alreadyImported, }: { workout: { id: string; name: string; fileUrl?: string; startedAt: string; distance: number | null; duration: number | null }; provider: { id: string; name: string }; 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.fileUrl && ( {t("sync.noGps")} )}
    {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 workoutFormData(w: { id: string; name: string; fileUrl?: 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("fileUrl", w.fileUrl ?? ""); 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 SyncImportPage({ loaderData }: Route.ComponentProps) { const { provider, 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(() => { if (!importAllActive) return; if (importAllFetcher.state !== "idle") return; // Previous import finished — advance to next 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(workoutFormData(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(workoutFormData(importableWorkouts[0]!), { method: "post" }); }, [importableWorkouts, importAllFetcher]); return (

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

    {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")} )}
    )}
    ); }