diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts
index 0292bf9..51606b0 100644
--- a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts
+++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts
@@ -100,3 +100,106 @@ describe("wahooImporter.listImportable", () => {
expect(result.workouts.map((w) => w.id)).toEqual(["1"]);
});
});
+
+describe("wahooImporter.importOne pagination", () => {
+ function fitToGpxMock() {
+ // The importer calls fitToGpx — short-circuit it so the test focuses
+ // on pagination, not FIT parsing.
+ return Promise.resolve("");
+ }
+
+ it("paginates past page 1 until it finds the target workout", async () => {
+ vi.resetModules();
+ vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock }));
+ vi.doMock("../../../sync/imports.server.ts", () => ({
+ importActivity: vi.fn().mockResolvedValue({ activityId: "a-1" }),
+ isAlreadyImported: vi.fn().mockResolvedValue(false),
+ }));
+ vi.doMock("../../manager.ts", () => ({
+ getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }),
+ }));
+
+ // Page 1: workouts 1..30, Page 2: workouts 31..50 (the target = 42)
+ fetchSpy.mockImplementation((url) => {
+ const u = String(url);
+ if (u.includes("page=2")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ workouts: [
+ {
+ id: 42,
+ name: "Old ride",
+ workout_type: "biking",
+ starts: "2025-12-01T07:00:00Z",
+ workout_summary: { file: { url: "https://cdn.example/42.fit" } },
+ },
+ ],
+ total: 50,
+ page: 2,
+ per_page: 30,
+ }),
+ { status: 200 },
+ ),
+ );
+ }
+ if (u.includes("/42.fit")) {
+ return Promise.resolve(new Response(new ArrayBuffer(4), { status: 200 }));
+ }
+ // page 1 by default — does NOT contain id 42
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ workouts: [
+ { id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
+ ],
+ total: 50,
+ page: 1,
+ per_page: 30,
+ }),
+ { status: 200 },
+ ),
+ );
+ });
+
+ const { wahooImporter } = await import("./importer.ts");
+ const result = await wahooImporter.importOne(ctxWith(), "42");
+ expect(result.activityId).toBe("a-1");
+ // Fetched at least pages 1 and 2 of the /v1/workouts endpoint
+ const workoutCalls = fetchSpy.mock.calls.filter(([u]) =>
+ String(u).includes("/v1/workouts?"),
+ );
+ expect(workoutCalls.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("throws a clear error when the workout is not found on any page", async () => {
+ vi.resetModules();
+ vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock }));
+ vi.doMock("../../../sync/imports.server.ts", () => ({
+ importActivity: vi.fn(),
+ isAlreadyImported: vi.fn().mockResolvedValue(false),
+ }));
+ vi.doMock("../../manager.ts", () => ({
+ getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }),
+ }));
+
+ fetchSpy.mockImplementation(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify({
+ workouts: [
+ { id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
+ ],
+ total: 1,
+ page: 1,
+ per_page: 30,
+ }),
+ { status: 200 },
+ ),
+ ),
+ );
+
+ const { wahooImporter } = await import("./importer.ts");
+ await expect(wahooImporter.importOne(ctxWith(), "999")).rejects.toThrow(/not found/);
+ });
+});
diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts
index 69637fa..f4a819f 100644
--- a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts
+++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts
@@ -104,15 +104,30 @@ export const wahooImporter: Importer = {
ctx: CapabilityContext,
workoutId: string,
): Promise {
- // Look up the workout to get the file URL (Wahoo doesn't expose a
- // direct /v1/workouts/ with file; we re-fetch the page).
- // For simplicity we ask Wahoo for the workout directly; if that fails
- // we fall back to scanning page 1.
- const list = await ctx.withFreshCredentials((creds) =>
- fetchWahooWorkoutPage(creds as OAuthCredentials, 1),
- );
- const workout = list.workouts.find((w) => String(w.id) === workoutId);
- if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`);
+ // Wahoo doesn't expose a direct /v1/workouts/ endpoint with file
+ // URL, so we paginate /v1/workouts looking for the target. Bound by
+ // the total / per_page Wahoo returns on page 1, with a hard ceiling
+ // so a misbehaving API can't loop us forever.
+ const MAX_PAGES = 100;
+ let workout: WahooWorkout | undefined;
+ let totalPages = 1;
+ for (let page = 1; page <= Math.min(totalPages, MAX_PAGES); page++) {
+ const list = await ctx.withFreshCredentials((creds) =>
+ fetchWahooWorkoutPage(creds as OAuthCredentials, page),
+ );
+ // perPage may not divide total cleanly; ceil so we don't stop one
+ // page short.
+ if (page === 1 && list.per_page > 0) {
+ totalPages = Math.ceil(list.total / list.per_page);
+ }
+ const found = list.workouts.find((w) => String(w.id) === workoutId);
+ if (found) {
+ workout = found;
+ break;
+ }
+ if (list.workouts.length === 0) break;
+ }
+ if (!workout) throw new Error(`Wahoo workout ${workoutId} not found`);
// Resolve the connected service's user id via the capability context.
// The caller (route handler) supplies userId out-of-band — for now the