fix(journal): extract loaders/actions for the remaining 21 mixed routes

Completes the .server.ts split started in #418. Every route that mixes
a default-export component with a server-only loader/action now has a
sibling <route>.server.ts holding the data-fetching helpers; the route
.tsx is a thin delegator.

Routes converted (21):
  activities._index, activities.$id, activities.new, auth.accept-terms,
  auth.verify, explore, feed, notifications, routes._index, routes.$id,
  routes.$id.edit, routes.new, settings, settings.account,
  settings.connections.komoot, settings.profile, settings.security,
  sync.import.$provider, sync.import.komoot, users.$username.followers,
  users.$username.following

Pattern (same as home.tsx / users.$username.tsx / settings.connections.tsx):
- loader → `return data(await loadX(request, params?))`
- action → `return await xAction(request, params?)`
- All `getDb` / Drizzle schema / `~/lib/*.server` imports move to the
  .server.ts sibling.
- `throw redirect(...)` and `throw data(...)` propagate through the
  delegator unchanged.

No behavior changes — pure module-graph cleanup. Component modules no
longer transitively import the DB client; Vite's tree-shake of
server-only code is now backed by an explicit, file-local contract.

Verified:
- pnpm typecheck — green
- pnpm lint — green
- pnpm test — 181 passed, 31 integration-gated skipped
- pnpm --filter @trails-cool/journal build — succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-24 11:05:40 +02:00
parent a57637868b
commit df562742e1
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
42 changed files with 1160 additions and 937 deletions

View file

@ -0,0 +1,118 @@
// Server-only loader/action for /sync/import/:provider. See `home.server.ts`.
import { data, redirect } from "react-router";
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";
export async function loadSyncImportProvider(request: Request, provider: string | undefined) {
const user = await requireSessionUser(request);
const manifest = getManifest(provider ?? "");
if (!manifest || !manifest.importer) {
throw data({ error: "Unknown provider" }, { status: 404 });
}
const service = await getService(user.id, manifest.id);
if (!service) throw 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 {
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 syncImportProviderAction(request: Request, provider: string | undefined) {
const user = await requireSessionUser(request);
const manifest = getManifest(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<Record<string, unknown>>((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 { imported: workoutId };
}