diff --git a/apps/journal/package.json b/apps/journal/package.json index 1e4c1b4..a8ed3d9 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -21,6 +21,7 @@ "@trails-cool/api": "workspace:*", "@trails-cool/db": "workspace:*", "@trails-cool/jobs": "workspace:*", + "@trails-cool/fit": "workspace:*", "@trails-cool/gpx": "workspace:*", "@trails-cool/i18n": "workspace:*", "@trails-cool/map": "workspace:*", diff --git a/openspec/changes/wahoo-route-push/design.md b/openspec/changes/wahoo-route-push/design.md index 09d57d6..f71b05c 100644 --- a/openspec/changes/wahoo-route-push/design.md +++ b/openspec/changes/wahoo-route-push/design.md @@ -23,17 +23,22 @@ There is no FIT *encoder* in the repo today — we only decode. FIT Course encod ## Decisions -### 1. Use a hand-rolled FIT Course encoder, not the official Garmin SDK +### 1. Use Garmin's official `@garmin/fitsdk` package server-side -**Decision**: Add a small (~300 LOC) hand-rolled FIT encoder to `packages/fit/` that emits exactly the message types Wahoo needs for a Course: `file_id`, `file_creator`, `course`, `lap`, `event` (start/stop), and `record`. No dependency on Garmin's SDK. +**Decision**: Depend on `@garmin/fitsdk` (the renamed, current package — formerly published as `@garmin/fitsdk-javascript`) and wrap it in `packages/fit/` with a single `gpxToFitCourse(...)` export. Encoding runs server-side in the Journal action route only — the SDK does not ship to the planner browser bundle. -**Why**: -- `@garmin/fitsdk-javascript` is the official option but ships ~1 MB of profile metadata and a CommonJS-only build that fights our ESM/Vite setup. Pulling it into the journal bundle is wasteful when we use ~5 message types. -- `fit-file-writer` (npm) is the closest community option, but unmaintained since 2021 and ships TypeScript types that disagree with the runtime API. We'd be patching it on day one. -- The FIT binary format is a documented, stable protocol (header + variable-length records + CRC). Encoding the seven messages we need is small enough to own. -- `fit-file-parser` (used today on the import path) gives us a free correctness oracle: every encoded file gets round-tripped through the parser in tests and asserted to match the source GPX track points. +**Why** (revised after re-evaluating the SDK as of 2026-04): +- The SDK is now **pure ESM** (`"type": "module"`, zero deps). The earlier ESM/Vite friction is gone. +- Actively maintained by Garmin, published quarterly in lockstep with the FIT profile (latest 21.202.0, Apr 2026). +- First-class Course encoder API (`new Encoder()` + `writeMesg(...)` + `close()` → `Uint8Array`) with an official cookbook at . +- The ~1 MB unpacked size is real but irrelevant: encoding lives behind `/api/sync/push/wahoo/:routeId` (Node 20). Nothing in the planner imports `@trails-cool/fit`. +- Avoids ~400 LOC of binary plumbing (file header, definition messages, data messages, CRC-16) that we'd otherwise own and maintain against future FIT spec updates. -**Alternatives considered**: `@garmin/fitsdk-javascript` (rejected: bundle size, ESM friction), `fit-file-writer` (rejected: unmaintained), shelling out to the Python `fit-tool` (rejected: adds a runtime dependency on the deploy host that doesn't already exist). +**Round-trip oracle**: `fit-file-parser` (already a dep on the import path) decodes every encoded fixture in tests and asserts track-point parity with the source GPX. Same correctness story as before — just with less code in our repo. + +**Cost**: TypeScript types aren't shipped, so `packages/fit/` includes a small ambient `fitsdk.d.ts` (~30 lines covering `Encoder`, `Stream`, `Profile.MesgNum`). + +**Alternatives considered**: hand-rolled encoder (rejected: 400 LOC of binary format we'd own forever, plus ongoing maintenance against FIT spec updates — net loss now that the SDK's ESM/maintenance objections are resolved); `fit-file-writer` (rejected: appears unpublished from npm registry); shelling out to Python `fit-tool` (rejected: new runtime dependency on the deploy host). ### 2. New `packages/fit/` package, GPX-shaped public API diff --git a/openspec/changes/wahoo-route-push/proposal.md b/openspec/changes/wahoo-route-push/proposal.md index e3654ad..202802d 100644 --- a/openspec/changes/wahoo-route-push/proposal.md +++ b/openspec/changes/wahoo-route-push/proposal.md @@ -27,7 +27,7 @@ The existing Wahoo integration is read-only: we pull completed workouts back int - `apps/journal/app/routes/` — new `/api/sync/push/wahoo/:routeId` action; "Send to Wahoo" button on the route detail page. - `packages/db/src/schema/journal.ts` — new `sync_pushes` table + `granted_scopes` column on `sync_connections`, plus the generated Drizzle migration in `packages/db/migrations/`. - **New package** `packages/fit/` — GPX→FIT Course encoder, co-located tests with FIT-decoder round-trip fixtures. -- **Dependencies**: One new dep — a JS FIT encoder. Candidates to evaluate in design.md: `@garmin/fitsdk-javascript` (official, large), `fit-file-writer`, or a thin hand-rolled encoder against the FIT SDK profile (Course message + Record + Lap). +- **Dependencies**: One new dep — `@garmin/fitsdk` (Garmin's official ESM-native FIT SDK; see design.md §1 for evaluation). `fit-file-parser` is already a dep on the import path and is reused as the round-trip oracle in tests. - **External API**: Net-new outbound calls to `api.wahooligan.com/v1/routes`. No webhook changes. - **Privacy manifest**: Update `docs/privacy.md` (or wherever the manifest lives) to declare that route geometry + name + description are sent to Wahoo when the user opts in by clicking "Send to Wahoo". - **Out of scope for this change**: bulk push ("send all my routes"), automatic push on route save, push to non-Wahoo providers, and pushing route updates after the initial send (PUT /v1/routes/:id can come later — first send wins). diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/wahoo-route-push/tasks.md index 948f905..2d44cbf 100644 --- a/openspec/changes/wahoo-route-push/tasks.md +++ b/openspec/changes/wahoo-route-push/tasks.md @@ -1,13 +1,13 @@ ## 1. FIT Course encoder package -- [ ] 1.1 Scaffold `packages/fit/` (package.json, tsconfig, vitest config) following the pattern of `packages/gpx/` -- [ ] 1.2 Add the package to `pnpm-workspace.yaml` and reference it from `apps/journal/package.json` -- [ ] 1.3 Implement low-level FIT primitives: file header, definition messages, data messages, CRC-16. Cover only the field types needed (`enum`, `uint8`, `uint16`, `uint32`, `sint32`, `string`, `float32`) -- [ ] 1.4 Implement message encoders for `file_id` (type=course), `file_creator`, `course` (name, sport, capabilities), `lap` (start/end position, distance, elapsed time = 0), `event` (timer start at first record, timer stop at last), and `record` (position_lat, position_long, altitude, distance) -- [ ] 1.5 Implement `gpxToFitCourse({ gpx, name, description?, sport? })`: parse GPX via `@trails-cool/gpx`, compute distance and ascent, emit the message stream, return `Uint8Array` -- [ ] 1.6 Add fixtures in `packages/fit/__fixtures__/` covering: short flat route, alpine route with elevation, multi-day route, route with single track point (should still encode), zero-track-point route (should throw) -- [ ] 1.7 Write round-trip tests: encode each fixture, decode with `fit-file-parser`, assert track-point count and lat/lon/elevation parity within tolerance (1e-5 deg, 0.5 m) -- [ ] 1.8 Add the package to `pnpm test` and `pnpm typecheck` runs (should be automatic via Turborepo) +- [x] 1.1 Scaffold `packages/fit/` (package.json, tsconfig, vitest config) following the pattern of `packages/gpx/` +- [x] 1.2 Add the package to `pnpm-workspace.yaml` and reference it from `apps/journal/package.json` +- [x] 1.3 Add `@garmin/fitsdk` dependency to `packages/fit/package.json` and a small ambient `src/fitsdk.d.ts` declaring the `Encoder`, `Stream`, and `Profile.MesgNum` shapes we use (the SDK ships no types) +- [x] 1.4 Wrap the SDK Encoder to emit the Course message stream Wahoo needs: `file_id` (type=course), `file_creator`, `course` (name, sport, capabilities), `lap` (start/end position, distance, elapsed time = 0), `event` (timer start at first record, timer stop at last), and `record` (position_lat, position_long, altitude, distance) +- [x] 1.5 Implement `gpxToFitCourse({ gpx, name, description?, sport? })`: parse GPX via `@trails-cool/gpx`, compute distance and ascent, drive the SDK encoder, return `Uint8Array` +- [x] 1.6 Add fixtures in `packages/fit/__fixtures__/` covering: short flat route, alpine route with elevation, multi-day route, route with single track point (should still encode), zero-track-point route (should throw) +- [x] 1.7 Write round-trip tests: encode each fixture, decode with `fit-file-parser`, assert track-point count and lat/lon/elevation parity within tolerance (1e-5 deg, 0.5 m) +- [x] 1.8 Add the package to `pnpm test` and `pnpm typecheck` runs (should be automatic via Turborepo) ## 2. Database schema for push tracking @@ -59,7 +59,7 @@ ## 9. Tests -- [ ] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7 +- [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7 - [ ] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit - [ ] 9.3 Unit tests for the scope-mismatch redirect flow - [ ] 9.4 E2E test in `e2e/`: log in as a user with a (mocked) Wahoo connection, open a route detail page, click "Send to Wahoo", assert the success toast and the "Sent to Wahoo on …" status appear diff --git a/packages/fit/__fixtures__/alpine.gpx b/packages/fit/__fixtures__/alpine.gpx new file mode 100644 index 0000000..c59c7e6 --- /dev/null +++ b/packages/fit/__fixtures__/alpine.gpx @@ -0,0 +1,16 @@ + + + Alpine climb + + 800.0 + 950.5 + 1100.2 + 1280.7 + 1450.0 + 1620.3 + 1800.0 + 1750.0 + 1600.0 + 1400.0 + + diff --git a/packages/fit/__fixtures__/empty.gpx b/packages/fit/__fixtures__/empty.gpx new file mode 100644 index 0000000..cf4dc50 --- /dev/null +++ b/packages/fit/__fixtures__/empty.gpx @@ -0,0 +1,5 @@ + + + Empty + + diff --git a/packages/fit/__fixtures__/multi-day.gpx b/packages/fit/__fixtures__/multi-day.gpx new file mode 100644 index 0000000..e143bed --- /dev/null +++ b/packages/fit/__fixtures__/multi-day.gpx @@ -0,0 +1,21 @@ + + + Multi-day tour + + 500 + 650 + 800 + 720 + 600 + 550 + 700 + 900 + 1100 + 1050 + 900 + 750 + 600 + 500 + 450 + + diff --git a/packages/fit/__fixtures__/short-flat.gpx b/packages/fit/__fixtures__/short-flat.gpx new file mode 100644 index 0000000..9fde094 --- /dev/null +++ b/packages/fit/__fixtures__/short-flat.gpx @@ -0,0 +1,11 @@ + + + Short flat loop + Short flat loop + 34.0 + 34.5 + 35.0 + 34.5 + 34.0 + + diff --git a/packages/fit/__fixtures__/single-point.gpx b/packages/fit/__fixtures__/single-point.gpx new file mode 100644 index 0000000..e409b35 --- /dev/null +++ b/packages/fit/__fixtures__/single-point.gpx @@ -0,0 +1,7 @@ + + + Single point + + 35 + + diff --git a/packages/fit/package.json b/packages/fit/package.json new file mode 100644 index 0000000..1e90ad3 --- /dev/null +++ b/packages/fit/package.json @@ -0,0 +1,23 @@ +{ + "name": "@trails-cool/fit", + "version": "0.0.1", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "test": "vitest run", + "lint": "eslint .", + "typecheck": "tsc" + }, + "dependencies": { + "@garmin/fitsdk": "^21.202.0", + "@trails-cool/gpx": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "fit-file-parser": "^2.3.3" + } +} diff --git a/packages/fit/src/fitsdk.d.ts b/packages/fit/src/fitsdk.d.ts new file mode 100644 index 0000000..8bfb72a --- /dev/null +++ b/packages/fit/src/fitsdk.d.ts @@ -0,0 +1,39 @@ +declare module "@garmin/fitsdk" { + export class Encoder { + constructor(options?: { fieldDescriptions?: unknown }); + writeMesg(mesg: { mesgNum: number; [field: string]: unknown }): this; + onMesg(mesgNum: number, mesg: Record): this; + close(): Uint8Array; + } + + export class Stream { + static fromArrayBuffer(buf: ArrayBuffer): Stream; + static fromBuffer(buf: Uint8Array | Buffer): Stream; + } + + export class Decoder { + constructor(stream: Stream); + read(opts?: Record): { + messages: Record>>; + errors: unknown[]; + }; + } + + export const Profile: { + version: { major: number; minor: number }; + MesgNum: { + FILE_ID: number; + FILE_CREATOR: number; + COURSE: number; + LAP: number; + RECORD: number; + EVENT: number; + [key: string]: number; + }; + }; + + export const Utils: { + convertDateToDateTime(date: Date): number; + convertDateTimeToDate(dt: number): Date; + }; +} diff --git a/packages/fit/src/gpx-to-fit-course.test.ts b/packages/fit/src/gpx-to-fit-course.test.ts new file mode 100644 index 0000000..8607f3d --- /dev/null +++ b/packages/fit/src/gpx-to-fit-course.test.ts @@ -0,0 +1,76 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; +import FitParser from "fit-file-parser"; + +import { parseGpxAsync } from "@trails-cool/gpx"; + +import { gpxToFitCourse } from "./gpx-to-fit-course.ts"; + +const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../__fixtures__"); + +async function loadFixture(name: string): Promise { + return readFile(resolve(FIXTURES_DIR, name), "utf8"); +} + +interface ParsedFit { + records: Array<{ position_lat?: number; position_long?: number; altitude?: number }>; + course?: Array<{ name?: string; sport?: string }> | { name?: string; sport?: string }; + laps?: Array; +} + +function decode(bytes: Uint8Array): Promise { + return new Promise((res, rej) => { + const parser = new FitParser({ force: true, mode: "list", lengthUnit: "m", speedUnit: "m/s" }); + parser.parse(Buffer.from(bytes), (err, data) => { + if (err) rej(new Error(err)); + else res(data as unknown as ParsedFit); + }); + }); +} + +const FIXTURES = ["short-flat.gpx", "alpine.gpx", "multi-day.gpx", "single-point.gpx"] as const; + +describe("gpxToFitCourse", () => { + for (const fixture of FIXTURES) { + it(`encodes ${fixture} round-trip via fit-file-parser`, async () => { + const gpx = await loadFixture(fixture); + const source = await parseGpxAsync(gpx); + const sourcePoints = source.tracks.flat(); + + const bytes = await gpxToFitCourse({ gpx, name: `Test ${fixture}` }); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.byteLength).toBeGreaterThan(20); + + const parsed = await decode(bytes); + const records = parsed.records ?? []; + expect(records.length).toBe(sourcePoints.length); + + for (let i = 0; i < sourcePoints.length; i++) { + const src = sourcePoints[i]!; + const got = records[i]!; + expect(got.position_lat).toBeCloseTo(src.lat, 4); + expect(got.position_long).toBeCloseTo(src.lon, 4); + if (src.ele !== undefined && got.altitude !== undefined) { + expect(Math.abs(got.altitude - src.ele)).toBeLessThan(0.5); + } + } + }); + } + + it("throws on a GPX with zero track points", async () => { + const gpx = await loadFixture("empty.gpx"); + await expect(gpxToFitCourse({ gpx, name: "Empty" })).rejects.toThrow(/zero track points/); + }); + + it("encodes course name and sport", async () => { + const gpx = await loadFixture("short-flat.gpx"); + const bytes = await gpxToFitCourse({ gpx, name: "Loop Test", sport: "cycling" }); + const parsed = await decode(bytes); + const course = Array.isArray(parsed.course) ? parsed.course[0] : parsed.course; + expect(course?.name).toBe("Loop Test"); + expect(course?.sport).toBe("cycling"); + }); +}); diff --git a/packages/fit/src/gpx-to-fit-course.ts b/packages/fit/src/gpx-to-fit-course.ts new file mode 100644 index 0000000..066bd0c --- /dev/null +++ b/packages/fit/src/gpx-to-fit-course.ts @@ -0,0 +1,117 @@ +import { Encoder, Profile } from "@garmin/fitsdk"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import type { TrackPoint } from "@trails-cool/gpx"; + +import { degToSemicircles } from "./semicircles.ts"; + +export type FitCourseSport = "cycling" | "running" | "hiking"; + +export interface GpxToFitCourseInput { + gpx: string; + name: string; + description?: string; + sport?: FitCourseSport; +} + +const SPORT_ENUM: Record = { + cycling: "cycling", + running: "running", + hiking: "hiking", +}; + +export async function gpxToFitCourse(input: GpxToFitCourseInput): Promise { + const data = await parseGpxAsync(input.gpx); + const points: TrackPoint[] = data.tracks.flat(); + + if (points.length === 0) { + throw new Error("Cannot encode FIT Course from a GPX with zero track points"); + } + + const first = points[0]!; + const last = points[points.length - 1]!; + const startTime = new Date("2020-01-01T00:00:00Z"); + + const encoder = new Encoder(); + + encoder.writeMesg({ + mesgNum: Profile.MesgNum.FILE_ID, + type: "course", + manufacturer: "development", + product: 0, + timeCreated: startTime, + serialNumber: 0, + }); + + encoder.writeMesg({ + mesgNum: Profile.MesgNum.FILE_CREATOR, + softwareVersion: 1, + hardwareVersion: 0, + }); + + encoder.writeMesg({ + mesgNum: Profile.MesgNum.COURSE, + name: input.name, + sport: SPORT_ENUM[input.sport ?? "cycling"], + capabilities: 0x00000004, + }); + + encoder.writeMesg({ + mesgNum: Profile.MesgNum.LAP, + timestamp: startTime, + startTime, + startPositionLat: degToSemicircles(first.lat), + startPositionLong: degToSemicircles(first.lon), + endPositionLat: degToSemicircles(last.lat), + endPositionLong: degToSemicircles(last.lon), + totalElapsedTime: 0, + totalTimerTime: 0, + totalDistance: data.distance, + }); + + encoder.writeMesg({ + mesgNum: Profile.MesgNum.EVENT, + timestamp: startTime, + event: "timer", + eventType: "start", + }); + + let cumulativeDistance = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]!; + if (i > 0) { + const prev = points[i - 1]!; + cumulativeDistance += haversine(prev.lat, prev.lon, p.lat, p.lon); + } + const mesg: { mesgNum: number; [field: string]: unknown } = { + mesgNum: Profile.MesgNum.RECORD, + timestamp: new Date(startTime.getTime() + i * 1000), + positionLat: degToSemicircles(p.lat), + positionLong: degToSemicircles(p.lon), + distance: cumulativeDistance, + }; + if (p.ele !== undefined) { + mesg.altitude = p.ele; + } + encoder.writeMesg(mesg); + } + + encoder.writeMesg({ + mesgNum: Profile.MesgNum.EVENT, + timestamp: new Date(startTime.getTime() + (points.length - 1) * 1000), + event: "timer", + eventType: "stopAll", + }); + + return encoder.close(); +} + +function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/packages/fit/src/index.ts b/packages/fit/src/index.ts new file mode 100644 index 0000000..e5a3aed --- /dev/null +++ b/packages/fit/src/index.ts @@ -0,0 +1,2 @@ +export { gpxToFitCourse } from "./gpx-to-fit-course.ts"; +export type { FitCourseSport, GpxToFitCourseInput } from "./gpx-to-fit-course.ts"; diff --git a/packages/fit/src/semicircles.ts b/packages/fit/src/semicircles.ts new file mode 100644 index 0000000..15f11ec --- /dev/null +++ b/packages/fit/src/semicircles.ts @@ -0,0 +1,9 @@ +const SEMICIRCLES_PER_DEGREE = 2 ** 31 / 180; + +export function degToSemicircles(deg: number): number { + return Math.round(deg * SEMICIRCLES_PER_DEGREE); +} + +export function semicirclesToDeg(semi: number): number { + return semi / SEMICIRCLES_PER_DEGREE; +} diff --git a/packages/fit/tsconfig.json b/packages/fit/tsconfig.json new file mode 100644 index 0000000..3fbff51 --- /dev/null +++ b/packages/fit/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/fit/vitest.config.ts b/packages/fit/vitest.config.ts new file mode 100644 index 0000000..8a07b14 --- /dev/null +++ b/packages/fit/vitest.config.ts @@ -0,0 +1 @@ +export { default } from "../../vitest.shared.ts"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c25860e..fc4432b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: '@trails-cool/db': specifier: workspace:* version: link:../../packages/db + '@trails-cool/fit': + specifier: workspace:* + version: link:../../packages/fit '@trails-cool/gpx': specifier: workspace:* version: link:../../packages/gpx @@ -592,6 +595,22 @@ importers: specifier: 'catalog:' version: 22.19.17 + packages/fit: + dependencies: + '@garmin/fitsdk': + specifier: ^21.202.0 + version: 21.202.0 + '@trails-cool/gpx': + specifier: workspace:* + version: link:../gpx + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.19.17 + fit-file-parser: + specifier: ^2.3.3 + version: 2.3.3 + packages/gpx: dependencies: '@trails-cool/types': @@ -1928,6 +1947,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.9.0 + '@garmin/fitsdk@21.202.0': + resolution: {integrity: sha512-WeveKUCKZSehRdM6JY/n0embblH18z5sY0KkJye5Z9nJ8Qv2S6EEmoSuBrW2ew45OtTGph0bu4/jxlO6VRP+Qw==} + '@geoman-io/leaflet-geoman-free@2.19.3': resolution: {integrity: sha512-HjbEpfAEUs0NyI1Dhvz3SMVG6m0pAN/1Eo0tRKsz9cpaROTrFtmJGY22swEir1Uj/8IeGF1NJId38C5Fu+nZGQ==} engines: {node: '>=18.0.0'} @@ -8942,6 +8964,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@garmin/fitsdk@21.202.0': {} + '@geoman-io/leaflet-geoman-free@2.19.3(leaflet@1.9.4)': dependencies: '@turf/boolean-contains': 7.3.4