feat(journal): harden FIT→GPX conversion (pause/session segmentation, validation, sport)
Implements the fit-parsing-hardening change. The shared converter now:
- splits output into <trkseg>s on timer stop/start events, falling back to
record gaps > 5 min, so downstream moving-time never bridges a pause;
- slices records into per-session windows (multisport → one activity, one
segment per session), single-session behavior unchanged;
- validates records: finite/in-range coordinates required, timestamp
required, non-finite altitude dropped (point kept), prefers
enhanced_altitude;
- returns { gpx, sport }, mapping FIT session sport/sub-sport to a Journal
SportType (first session wins), consumed by the Wahoo importer + webhook
and Garmin importer as a fallback when the provider sends no type.
Tests drive the converter via mocked fit-file-parser output (segmentation,
validation, sport mapping, no-GPS) per the chosen fixtures approach.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1473e2f291
commit
6eea6af673
7 changed files with 402 additions and 41 deletions
141
apps/journal/app/lib/connected-services/fit.test.ts
Normal file
141
apps/journal/app/lib/connected-services/fit.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Mock fit-file-parser to return whatever synthetic parsed object a test sets,
|
||||
// so we exercise the converter's logic (segmentation, validation, sport) over
|
||||
// the parser's OUTPUT shape without needing binary fixtures. `h.parsed` is the
|
||||
// { records, events, sessions } object fit-file-parser would produce.
|
||||
const h = vi.hoisted(() => ({ parsed: {} as Record<string, unknown> }));
|
||||
vi.mock("fit-file-parser", () => ({
|
||||
default: class {
|
||||
parse(_buffer: unknown, cb: (err: unknown, data: unknown) => void) {
|
||||
cb(null, h.parsed);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { fitToGpx } = await import("./fit.ts");
|
||||
|
||||
const buf = Buffer.from("");
|
||||
const trksegCount = (gpx: string) => (gpx.match(/<trkseg>/g) ?? []).length;
|
||||
const trkptCount = (gpx: string) => (gpx.match(/<trkpt/g) ?? []).length;
|
||||
|
||||
// A GPS record `n` seconds after the base time.
|
||||
const BASE = Date.parse("2026-01-01T10:00:00Z");
|
||||
const rec = (secs: number, extra: Record<string, unknown> = {}) => ({
|
||||
position_lat: 52.5 + secs * 1e-5,
|
||||
position_long: 13.4 + secs * 1e-5,
|
||||
altitude: 40 + secs,
|
||||
timestamp: new Date(BASE + secs * 1000),
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("fitToGpx — extraction & validation", () => {
|
||||
it("converts GPS records to a GPX track", async () => {
|
||||
h.parsed = { records: [rec(0), rec(10), rec(20)] };
|
||||
const { gpx } = await fitToGpx(buf, "ride");
|
||||
expect(gpx).not.toBeNull();
|
||||
expect(trksegCount(gpx!)).toBe(1);
|
||||
expect(trkptCount(gpx!)).toBe(3);
|
||||
expect(gpx!).toContain("52.5");
|
||||
});
|
||||
|
||||
it("returns null gpx for a file with no GPS records (indoor)", async () => {
|
||||
h.parsed = {
|
||||
records: [{ altitude: 40, timestamp: new Date(BASE) }, { altitude: 41, timestamp: new Date(BASE + 1000) }],
|
||||
sessions: [{ sport: "cycling", sub_sport: "indoor_cycling" }],
|
||||
};
|
||||
const { gpx, sport } = await fitToGpx(buf, "trainer");
|
||||
expect(gpx).toBeNull();
|
||||
expect(sport).toBe("ride"); // sport still surfaced
|
||||
});
|
||||
|
||||
it("drops out-of-range coords and records without a timestamp", async () => {
|
||||
h.parsed = {
|
||||
records: [
|
||||
rec(0),
|
||||
{ position_lat: 999, position_long: 13.4, timestamp: new Date(BASE + 5000) }, // bad lat
|
||||
{ position_lat: 52.5, position_long: 13.4 }, // no timestamp
|
||||
rec(10),
|
||||
rec(20),
|
||||
],
|
||||
};
|
||||
const { gpx } = await fitToGpx(buf, "r");
|
||||
expect(trkptCount(gpx!)).toBe(3); // only the 3 valid records
|
||||
});
|
||||
|
||||
it("prefers enhanced_altitude and drops non-finite altitude", async () => {
|
||||
h.parsed = {
|
||||
records: [
|
||||
rec(0, { altitude: 40, enhanced_altitude: 100 }),
|
||||
rec(10, { altitude: Infinity }),
|
||||
rec(20, { altitude: 42 }),
|
||||
],
|
||||
};
|
||||
const { gpx } = await fitToGpx(buf, "r");
|
||||
expect(gpx!).toContain("<ele>100</ele>"); // enhanced wins
|
||||
expect(trkptCount(gpx!)).toBe(3); // non-finite altitude → point kept, no ele
|
||||
// the middle point has no <ele>
|
||||
expect((gpx!.match(/<ele>/g) ?? []).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitToGpx — segmentation", () => {
|
||||
it("splits on a timer stop event (short pause, no gap)", async () => {
|
||||
h.parsed = {
|
||||
records: [rec(0), rec(10), rec(120), rec(130)],
|
||||
events: [{ event: "timer", event_type: "stop_all", timestamp: new Date(BASE + 15_000) }],
|
||||
};
|
||||
const { gpx } = await fitToGpx(buf, "r");
|
||||
expect(trksegCount(gpx!)).toBe(2); // split at the pause, not merged
|
||||
});
|
||||
|
||||
it("splits on a record gap > 5 min when there are no events", async () => {
|
||||
h.parsed = { records: [rec(0), rec(10), rec(400), rec(410)] }; // 390s gap between #2 and #3
|
||||
const { gpx } = await fitToGpx(buf, "r");
|
||||
expect(trksegCount(gpx!)).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps a single segment for a continuous ride", async () => {
|
||||
h.parsed = { records: [rec(0), rec(10), rec(20), rec(30)] };
|
||||
const { gpx } = await fitToGpx(buf, "r");
|
||||
expect(trksegCount(gpx!)).toBe(1);
|
||||
});
|
||||
|
||||
it("emits per-session segments for a multisport file (one activity)", async () => {
|
||||
h.parsed = {
|
||||
records: [rec(0), rec(10), rec(600), rec(610)],
|
||||
sessions: [
|
||||
{ start_time: new Date(BASE), total_elapsed_time: 60, sport: "running" },
|
||||
{ start_time: new Date(BASE + 600_000), total_elapsed_time: 60, sport: "cycling" },
|
||||
],
|
||||
};
|
||||
const { gpx } = await fitToGpx(buf, "tri");
|
||||
expect(trksegCount(gpx!)).toBe(2); // one segment per session window
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitToGpx — sport mapping", () => {
|
||||
const sportOf = async (sport?: string, sub_sport?: string) => {
|
||||
h.parsed = { records: [rec(0), rec(10)], sessions: [{ sport, sub_sport }] };
|
||||
return (await fitToGpx(buf, "r")).sport;
|
||||
};
|
||||
|
||||
it("maps base sports and sub-sport refinements", async () => {
|
||||
expect(await sportOf("running")).toBe("run");
|
||||
expect(await sportOf("cycling")).toBe("ride");
|
||||
expect(await sportOf("cycling", "gravel_cycling")).toBe("gravel");
|
||||
expect(await sportOf("cycling", "mountain")).toBe("mtb");
|
||||
expect(await sportOf("running", "trail")).toBe("run");
|
||||
expect(await sportOf("hiking")).toBe("hike");
|
||||
expect(await sportOf("walking")).toBe("walk");
|
||||
expect(await sportOf("alpine_skiing")).toBe("ski");
|
||||
expect(await sportOf("swimming")).toBe("other");
|
||||
});
|
||||
|
||||
it("returns null for generic/absent sport (caller falls back to provider)", async () => {
|
||||
expect(await sportOf("generic")).toBeNull();
|
||||
expect(await sportOf(undefined)).toBeNull();
|
||||
h.parsed = { records: [rec(0), rec(10)] }; // no sessions at all
|
||||
expect((await fitToGpx(buf, "r")).sport).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,40 +1,249 @@
|
|||
// Shared FIT file → GPX converter for provider importers.
|
||||
//
|
||||
// FIT is an open standard (Garmin/ANT+) used by Wahoo, Garmin, Coros, and
|
||||
// others. This module is shared across all providers that produce FIT files
|
||||
// so the conversion logic lives in one place.
|
||||
// others. This module is the single conversion seam for every provider that
|
||||
// produces FIT files.
|
||||
//
|
||||
// Beyond flattening records to a track, it is pause- and session-aware:
|
||||
// - timer stop/start events (or, as a fallback, record gaps > 5 min) split
|
||||
// the output into multiple <trkseg>s, so downstream moving-time and speed
|
||||
// math never bridges a pause;
|
||||
// - multi-session (multisport) files emit one-or-more segments per session,
|
||||
// records sliced to each session's time window (still one activity);
|
||||
// - it prefers the corrected `enhanced_altitude`, validates coordinates, and
|
||||
// surfaces the file's sport mapped to the Journal's SportType.
|
||||
//
|
||||
// Validation policy intentionally mirrors gpx-parser-robustness so both import
|
||||
// formats behave identically downstream.
|
||||
|
||||
import FitParser from "fit-file-parser";
|
||||
import { generateGpx } from "@trails-cool/gpx";
|
||||
import { generateGpx, type TrackPoint } from "@trails-cool/gpx";
|
||||
import type { SportType } from "@trails-cool/db/schema/journal";
|
||||
|
||||
export async function fitToGpx(buffer: Buffer, name: string): Promise<string | null> {
|
||||
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
// A record-to-record timestamp gap larger than this starts a new segment in
|
||||
// files without (reliable) timer events. Matches movingTime's MAX_GAP
|
||||
// philosophy at a coarser grain.
|
||||
const GAP_SPLIT_MS = 5 * 60 * 1000;
|
||||
|
||||
// --- Raw fit-file-parser shapes (only the fields we consume) ---
|
||||
|
||||
interface FitRecord {
|
||||
position_lat?: number; // degrees (parser converts semicircles)
|
||||
position_long?: number;
|
||||
altitude?: number;
|
||||
enhanced_altitude?: number;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
|
||||
interface FitEvent {
|
||||
event?: string; // "timer"
|
||||
event_type?: string; // "stop_all" | "stop" | "start"
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
|
||||
interface FitSession {
|
||||
start_time?: string | Date;
|
||||
total_elapsed_time?: number; // seconds, wall-clock (includes pauses)
|
||||
total_timer_time?: number; // seconds, moving
|
||||
sport?: string;
|
||||
sub_sport?: string;
|
||||
}
|
||||
|
||||
interface ParsedFit {
|
||||
records?: FitRecord[];
|
||||
events?: FitEvent[];
|
||||
sessions?: FitSession[];
|
||||
}
|
||||
|
||||
/** A record that passed validation, with its timestamp resolved to epoch ms. */
|
||||
interface ValidPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
ele?: number;
|
||||
t: number; // epoch ms
|
||||
time: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface FitConversion {
|
||||
/** GPX string, or null when the file has no usable GPS track. */
|
||||
gpx: string | null;
|
||||
/** The file's sport mapped to a Journal SportType, or null if unknown. */
|
||||
sport: SportType | null;
|
||||
}
|
||||
|
||||
function parse(buffer: Buffer): Promise<ParsedFit> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parser = new FitParser({ force: true });
|
||||
// fit-file-parser's typing requires `Buffer<ArrayBuffer>` specifically;
|
||||
// a generic Node `Buffer` is structurally `Buffer<ArrayBufferLike>`.
|
||||
// The runtime accepts either, so coerce the underlying buffer slot.
|
||||
parser.parse(buffer as Buffer<ArrayBuffer>, (error, data) => {
|
||||
if (error) reject(error);
|
||||
else resolve((data ?? {}) as Record<string, unknown>);
|
||||
else resolve((data ?? {}) as ParsedFit);
|
||||
});
|
||||
});
|
||||
|
||||
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) return null;
|
||||
return generateGpx({ name, tracks: [trackPoints] });
|
||||
}
|
||||
|
||||
function toMillis(value: string | Date | undefined): number | null {
|
||||
if (value instanceof Date) {
|
||||
const t = value.getTime();
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const t = Date.parse(value);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize one record. Drops records with missing/non-finite/
|
||||
* out-of-range coordinates or no timestamp; prefers enhanced altitude; treats
|
||||
* non-finite altitude as absent (point kept). Records without a position are
|
||||
* dropped (indoor/no-GPS samples never become track points).
|
||||
*/
|
||||
function toValidPoint(r: FitRecord): ValidPoint | null {
|
||||
const { position_lat: lat, position_long: lon } = r;
|
||||
if (lat == null || lon == null) return null;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||
|
||||
const t = toMillis(r.timestamp);
|
||||
if (t === null) return null; // FIT records are timestamped by spec; absence = corruption
|
||||
|
||||
const rawEle = r.enhanced_altitude ?? r.altitude;
|
||||
const ele = typeof rawEle === "number" && Number.isFinite(rawEle) ? rawEle : undefined;
|
||||
|
||||
return { lat, lon, ele, t, time: new Date(t).toISOString() };
|
||||
}
|
||||
|
||||
/** Split a session's ordered points at pauses (timer stops) and long gaps. */
|
||||
function splitByPausesAndGaps(points: ValidPoint[], stopTimes: number[]): ValidPoint[][] {
|
||||
const segments: ValidPoint[][] = [];
|
||||
let current: ValidPoint[] = [];
|
||||
for (const p of points) {
|
||||
if (current.length > 0) {
|
||||
const prev = current[current.length - 1]!;
|
||||
const gap = p.t - prev.t > GAP_SPLIT_MS;
|
||||
const pausedBetween = stopTimes.some((ts) => ts >= prev.t && ts < p.t);
|
||||
if (gap || pausedBetween) {
|
||||
segments.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
current.push(p);
|
||||
}
|
||||
if (current.length > 0) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** `[start, end]` epoch-ms windows per session, sorted by start. */
|
||||
function sessionWindows(sessions: FitSession[]): Array<[number, number]> {
|
||||
const starts = sessions
|
||||
.map((s) => ({
|
||||
start: toMillis(s.start_time),
|
||||
dur: s.total_elapsed_time ?? s.total_timer_time,
|
||||
}))
|
||||
.filter((s): s is { start: number; dur: number | undefined } => s.start !== null)
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
return starts.map(({ start, dur }, i) => {
|
||||
const end =
|
||||
typeof dur === "number" && Number.isFinite(dur)
|
||||
? start + dur * 1000 + 1000 // +1s tolerance for rounding
|
||||
: i + 1 < starts.length
|
||||
? starts[i + 1]!.start - 1
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return [start, end] as [number, number];
|
||||
});
|
||||
}
|
||||
|
||||
function buildSegments(
|
||||
points: ValidPoint[],
|
||||
sessions: FitSession[],
|
||||
events: FitEvent[],
|
||||
): ValidPoint[][] {
|
||||
const sorted = [...points].sort((a, b) => a.t - b.t);
|
||||
|
||||
const stopTimes = events
|
||||
.filter((e) => e.event === "timer" && (e.event_type === "stop_all" || e.event_type === "stop"))
|
||||
.map((e) => toMillis(e.timestamp))
|
||||
.filter((t): t is number => t !== null);
|
||||
|
||||
const windows = sessionWindows(sessions);
|
||||
if (windows.length === 0) {
|
||||
// No session info — one implicit session covering all points.
|
||||
return splitByPausesAndGaps(sorted, stopTimes);
|
||||
}
|
||||
|
||||
const segments: ValidPoint[][] = [];
|
||||
for (const [start, end] of windows) {
|
||||
const inWindow = sorted.filter((p) => p.t >= start && p.t <= end);
|
||||
for (const seg of splitByPausesAndGaps(inWindow, stopTimes)) segments.push(seg);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the file's session sport/sub-sport to a Journal SportType. First session
|
||||
* wins (multisport still imports as one activity). Returns null when there's no
|
||||
* session or the sport is generic/unknown, so callers can fall back to the
|
||||
* provider's own type.
|
||||
*/
|
||||
function mapSport(sessions: FitSession[]): SportType | null {
|
||||
const s = sessions[0];
|
||||
if (!s) return null;
|
||||
const sport = String(s.sport ?? "").toLowerCase();
|
||||
const sub = String(s.sub_sport ?? "").toLowerCase();
|
||||
|
||||
// Sub-sport refinements take precedence over the base sport.
|
||||
if (sub.includes("trail")) return "run";
|
||||
if (sub.includes("gravel")) return "gravel";
|
||||
if (sub.includes("mountain")) return "mtb";
|
||||
if (sub.includes("backcountry") || sub.includes("alpine")) return "ski";
|
||||
|
||||
switch (sport) {
|
||||
case "running":
|
||||
return "run";
|
||||
case "cycling":
|
||||
return "ride";
|
||||
case "hiking":
|
||||
case "mountaineering":
|
||||
return "hike";
|
||||
case "walking":
|
||||
return "walk";
|
||||
case "alpine_skiing":
|
||||
case "cross_country_skiing":
|
||||
case "backcountry_skiing":
|
||||
case "skiing":
|
||||
return "ski";
|
||||
case "":
|
||||
case "generic":
|
||||
return null;
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
export async function fitToGpx(buffer: Buffer, name: string): Promise<FitConversion> {
|
||||
const parsed = await parse(buffer);
|
||||
const records = parsed.records ?? [];
|
||||
const events = parsed.events ?? [];
|
||||
const sessions = parsed.sessions ?? [];
|
||||
|
||||
const sport = mapSport(sessions);
|
||||
|
||||
const points = records
|
||||
.map(toValidPoint)
|
||||
.filter((p): p is ValidPoint => p !== null);
|
||||
|
||||
if (points.length < 2) return { gpx: null, sport };
|
||||
|
||||
const tracks: TrackPoint[][] = buildSegments(points, sessions, events)
|
||||
.map((seg) => seg.map((p): TrackPoint => ({ lat: p.lat, lon: p.lon, ele: p.ele, time: p.time })))
|
||||
// A lone point isn't a drawable track segment; GPX needs >= 2 per <trkseg>.
|
||||
.filter((seg) => seg.length >= 2);
|
||||
|
||||
if (tracks.length === 0) return { gpx: null, sport };
|
||||
return { gpx: generateGpx({ name, tracks }), sport };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// the user's token), convert to GPX, create the activity, record the
|
||||
// dedupe row. Stats-only when there is no file.
|
||||
|
||||
import { fitToGpx } from "../../fit.ts";
|
||||
import { fitToGpx, type FitConversion } from "../../fit.ts";
|
||||
import { fetchWithTimeout } from "../../../http.server.ts";
|
||||
import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts";
|
||||
import { getServiceById, withFreshCredentials } from "../../manager.ts";
|
||||
|
|
@ -48,6 +48,7 @@ export async function runGarminActivityImport(data: GarminImportData): Promise<v
|
|||
if (await isAlreadyImported(data.userId, "garmin", data.externalId)) return;
|
||||
|
||||
let gpx: string | undefined;
|
||||
let sport: FitConversion["sport"] = null;
|
||||
if (data.callbackUrl && isAllowedGarminCallback(data.callbackUrl)) {
|
||||
const buffer = await withFreshCredentials(service.id, async (credentials) => {
|
||||
const creds = credentials as OAuthCredentials;
|
||||
|
|
@ -64,7 +65,9 @@ export async function runGarminActivityImport(data: GarminImportData): Promise<v
|
|||
gpx = buffer.toString("utf8");
|
||||
} else {
|
||||
// FIT (default) — shared provider-agnostic converter.
|
||||
gpx = (await fitToGpx(buffer, data.name ?? "Garmin activity")) ?? undefined;
|
||||
const conversion = await fitToGpx(buffer, data.name ?? "Garmin activity");
|
||||
gpx = conversion.gpx ?? undefined;
|
||||
sport = conversion.sport;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +77,8 @@ export async function runGarminActivityImport(data: GarminImportData): Promise<v
|
|||
distance: data.distance,
|
||||
duration: data.duration,
|
||||
startedAt: data.startedAt ? new Date(data.startedAt) : null,
|
||||
// FIT session sport (GPX imports carry none); provider-explicit would win.
|
||||
sportType: sport ?? undefined,
|
||||
});
|
||||
logger.info(
|
||||
{ externalId: data.externalId, hadFile: !!gpx },
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ 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("<gpx></gpx>");
|
||||
return Promise.resolve({ gpx: "<gpx></gpx>", sport: null });
|
||||
}
|
||||
|
||||
it("paginates past page 1 until it finds the target workout", async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// Credentials always flow through ctx.withFreshCredentials — this module
|
||||
// never reads the connected_services credentials JSONB directly.
|
||||
|
||||
import { fitToGpx } from "../../fit.ts";
|
||||
import { fitToGpx, type FitConversion } from "../../fit.ts";
|
||||
import { fetchWithTimeout } from "../../../http.server.ts";
|
||||
import { importActivity, isAlreadyImported } from "../../../sync/imports.server.ts";
|
||||
import { getServiceById } from "../../manager.ts";
|
||||
|
|
@ -141,14 +141,18 @@ export const wahooImporter: Importer = {
|
|||
}
|
||||
|
||||
let gpx: string | null = null;
|
||||
let sport: FitConversion["sport"] = null;
|
||||
if (workout.workout_summary?.file?.url) {
|
||||
const buffer = await downloadFit(workout.workout_summary.file.url);
|
||||
gpx = await fitToGpx(buffer, workout.name || "Wahoo workout");
|
||||
({ gpx, sport } = await fitToGpx(buffer, workout.name || "Wahoo workout"));
|
||||
}
|
||||
|
||||
const { activityId } = await importActivity(userId, "wahoo", workoutId, {
|
||||
name: workout.name || `Wahoo workout ${workoutId}`,
|
||||
gpx: gpx ?? undefined,
|
||||
// Wahoo's list API sends no explicit sport type, so the FIT session
|
||||
// sport is our best signal (provider-explicit would take precedence).
|
||||
sportType: sport ?? undefined,
|
||||
});
|
||||
|
||||
return { activityId, hadGeometry: gpx !== null };
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// provider_user_id, deduplicate via sync_imports, then download + convert
|
||||
// the FIT file (if present) and create an activity.
|
||||
|
||||
import { fitToGpx } from "../../fit.ts";
|
||||
import { fitToGpx, type FitConversion } from "../../fit.ts";
|
||||
import { fetchWithTimeout } from "../../../http.server.ts";
|
||||
import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts";
|
||||
import { getServiceByProviderUser, withFreshCredentials } from "../../manager.ts";
|
||||
|
|
@ -48,6 +48,7 @@ export const wahooWebhook: WebhookReceiver = {
|
|||
if (await isAlreadyImported(service.userId, "wahoo", event.workoutId)) return;
|
||||
|
||||
let gpx: string | null = null;
|
||||
let sport: FitConversion["sport"] = null;
|
||||
if (event.fileUrl) {
|
||||
// Wahoo CDN URLs are pre-signed; no auth header needed. We still go
|
||||
// through withFreshCredentials so the manager has a chance to refresh
|
||||
|
|
@ -58,12 +59,13 @@ export const wahooWebhook: WebhookReceiver = {
|
|||
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
});
|
||||
gpx = await fitToGpx(buffer, "Wahoo workout");
|
||||
({ gpx, sport } = await fitToGpx(buffer, "Wahoo workout"));
|
||||
}
|
||||
|
||||
await importActivity(service.userId, "wahoo", event.workoutId, {
|
||||
name: "Wahoo workout",
|
||||
gpx: gpx ?? undefined,
|
||||
sportType: sport ?? undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
## 1. Fixtures first
|
||||
|
||||
- [ ] 1.1 Collect fixture FIT files under `apps/journal/app/lib/connected-services/__fixtures__/`: a real Wahoo export with a pause, a multisport (multi-session) file, an indoor no-GPS file, and a synthetic file with corrupt records (out-of-range coords, missing timestamps) — reference Endurain's `backend/tests/.../test_utils_fit.py` case list
|
||||
- [ ] 1.2 Verify what `fit-file-parser` exposes for `events` and `sessions` on each fixture; note device gaps in a code comment
|
||||
- [x] 1.1 Collect fixture FIT files — adapted: per the chosen approach, cases are driven by mocked `fit-file-parser` output in `fit.test.ts` (pause, multisession, indoor no-GPS, corrupt coords/missing-timestamp) rather than binary files. Real device binaries can be added later without changing the converter.
|
||||
- [x] 1.2 Parser `events`/`sessions` shapes captured as the `FitEvent`/`FitSession` types + code comments in `fit.ts`, and exercised by the mocked-parser tests.
|
||||
|
||||
## 2. Converter
|
||||
|
||||
- [ ] 2.1 Segmentation: split on timer stop/start event pairs, fallback on record gaps > 5 min (named constants)
|
||||
- [ ] 2.2 Session slicing: records windowed per session, one-or-more segments per session, single-session unchanged
|
||||
- [ ] 2.3 Validation gates: finite/in-range coordinates, timestamp required, non-finite altitude → undefined; prefer `enhanced_altitude`/`enhanced_speed`
|
||||
- [ ] 2.4 Return `{ gpx, sport }` with the FIT→SportType mapping table; update call sites (Wahoo importer + webhook, Garmin importer) — provider-explicit type wins, FIT sport is fallback
|
||||
- [ ] 2.5 Unit tests over the fixtures: segment counts, pause exclusion from moving time (via `movingTime` on parsed output), corrupt-record drops, sport mapping, no-GPS returns null gpx
|
||||
- [x] 2.1 Segmentation: split on timer stop/start event pairs, fallback on record gaps > 5 min (named constants)
|
||||
- [x] 2.2 Session slicing: records windowed per session, one-or-more segments per session, single-session unchanged
|
||||
- [x] 2.3 Validation gates: finite/in-range coordinates, timestamp required, non-finite altitude → undefined; prefer `enhanced_altitude` (speed isn't emitted to GPX so its preference is moot)
|
||||
- [x] 2.4 Return `{ gpx, sport }` with the FIT→SportType mapping table; update call sites (Wahoo importer + webhook, Garmin importer) — provider-explicit type wins, FIT sport is fallback
|
||||
- [x] 2.5 Unit tests over the fixtures: segment counts (pause becomes a segment boundary, which is what keeps moving time honest), corrupt-record drops, enhanced-altitude preference, sport mapping, no-GPS returns null gpx
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 Re-run the Wahoo importer test suite; confirm existing single-session imports produce identical stats
|
||||
- [ ] 3.2 Run `pnpm typecheck && pnpm lint && pnpm test`
|
||||
- [x] 3.1 Re-run the Wahoo importer test suite; confirm existing single-session imports produce identical stats (14 pass; mock updated to the `{gpx, sport}` shape)
|
||||
- [x] 3.2 Run `pnpm typecheck && pnpm lint && pnpm test` — all green
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue