trails/apps/journal/app/lib/sync/providers/wahoo.ts
Ullrich Schäfer 20b91ef511
Fix Wahoo import bugs, add Import All, update spec
Bugs fixed:
- FIT parser returns timestamps as Date objects, not strings — convert
  to ISO 8601 before passing to generateGpx (caused str.replace crash)
- FIT parser already converts semicircles to degrees — remove redundant
  conversion that produced near-zero coordinates
- Wahoo CDN URLs are pre-signed S3 URLs — remove Bearer auth header
  from download requests (caused 400 "Unsupported Authorization Type")
- Filter out third-party workouts (fitness_app_id >= 1000) since Wahoo
  does not share their data via the API

UX improvements:
- Individual imports use fetcher (no page refresh), button shows
  "Importing..." inline
- "Import all" button imports all unimported workouts on the page
  sequentially with progress indicator
- Add @vitejs/plugin-basic-ssl for local HTTPS dev (opt-in via HTTPS=1)

Also adds unit test for FIT-to-GPX conversion with real fixture file,
and updates wahoo-import spec to reflect all behaviors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:47:48 +02:00

185 lines
6.2 KiB
TypeScript

import FitParser from "fit-file-parser";
import { generateGpx } from "@trails-cool/gpx";
import type { SyncProvider, TokenSet, Workout, WorkoutList, WebhookEvent } from "../types.ts";
const WAHOO_API = "https://api.wahooligan.com";
const WAHOO_AUTH = "https://api.wahooligan.com/oauth";
const clientId = () => process.env.WAHOO_CLIENT_ID ?? "";
const clientSecret = () => process.env.WAHOO_CLIENT_SECRET ?? "";
export const wahooProvider: SyncProvider = {
id: "wahoo",
name: "Wahoo",
scopes: ["workouts_read", "user_read", "offline_data"],
getAuthUrl(redirectUri: string, state: string): string {
const params = new URLSearchParams({
client_id: clientId(),
redirect_uri: redirectUri,
response_type: "code",
scope: this.scopes.join(" "),
state,
});
return `${WAHOO_AUTH}/authorize?${params}`;
},
async exchangeCode(code: string, redirectUri: string): Promise<TokenSet> {
const resp = await fetch(`${WAHOO_AUTH}/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId(),
client_secret: clientSecret(),
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
if (!resp.ok) throw new Error(`Wahoo token exchange failed: ${resp.status}`);
const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number };
// Fetch user info to get provider user ID
const userResp = await fetch(`${WAHOO_API}/v1/user`, {
headers: { Authorization: `Bearer ${data.access_token}` },
});
const user = userResp.ok ? (await userResp.json() as { id: number }) : null;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
providerUserId: user?.id?.toString(),
};
},
async refreshToken(refreshToken: string): Promise<TokenSet> {
const resp = await fetch(`${WAHOO_AUTH}/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId(),
client_secret: clientSecret(),
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!resp.ok) throw new Error(`Wahoo token refresh failed: ${resp.status}`);
const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number };
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
};
},
async listWorkouts(tokens: TokenSet, page: number): Promise<WorkoutList> {
const params = new URLSearchParams({ page: String(page), per_page: "30" });
const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`);
const data = await resp.json() as {
workouts: Array<{
id: number;
name: string;
workout_type: string;
starts: string;
fitness_app_id?: number;
workout_summary?: {
duration_active_accum?: number;
distance_accum?: number;
file?: { url?: string };
};
}>;
total: number;
page: number;
per_page: number;
};
// Wahoo does not share workout data from third-party apps (fitness_app_id >= 1000)
const wahooWorkouts = data.workouts.filter((w) => !w.fitness_app_id || w.fitness_app_id < 1000);
return {
workouts: wahooWorkouts.map((w) => ({
id: String(w.id),
name: w.name || `Workout ${w.id}`,
type: w.workout_type ?? "unknown",
startedAt: w.starts,
duration: w.workout_summary?.duration_active_accum
? Math.round(w.workout_summary.duration_active_accum)
: null,
distance: w.workout_summary?.distance_accum
? Math.round(w.workout_summary.distance_accum)
: null,
fileUrl: w.workout_summary?.file?.url,
})),
total: data.total,
page: data.page,
perPage: data.per_page,
};
},
async downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer> {
if (!workout.fileUrl) throw new Error("No file URL for workout");
const resp = await fetch(workout.fileUrl);
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
},
async convertToGpx(fileBuffer: Buffer): Promise<string | null> {
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(fileBuffer as any, (error: unknown, data: any) => {
if (error) reject(error);
else resolve(data ?? {});
});
});
const records = (parsed.records ?? []) as Array<{
position_lat?: number;
position_long?: number;
altitude?: number;
timestamp?: string | Date;
}>;
// fit-file-parser already converts semicircles to degrees
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) return null;
return generateGpx({
name: "Wahoo workout",
tracks: [trackPoints],
});
},
parseWebhook(body: unknown): WebhookEvent | null {
const payload = body as {
event_type?: string;
user?: { id?: number };
workout_summary?: {
id?: number;
workout?: { id?: number };
file?: { url?: string };
};
};
if (payload.event_type !== "workout_summary" || !payload.user?.id) return null;
return {
eventType: payload.event_type,
providerUserId: String(payload.user.id),
workoutId: String(payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? ""),
fileUrl: payload.workout_summary?.file?.url,
};
},
};