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:
Ullrich Schäfer 2026-07-17 00:03:25 +02:00
parent 1473e2f291
commit 6eea6af673
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 402 additions and 41 deletions

View 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();
});
});

View file

@ -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 };
}

View file

@ -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 },

View file

@ -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 () => {

View file

@ -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 };

View file

@ -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,
});
},
};