Add @trails-cool/fit package with GPX→FIT Course encoder
Wraps @garmin/fitsdk to emit FIT Course files from GPX, the binary format Wahoo's POST /v1/routes API requires. Server-side only — the ~1 MB SDK never ships to the planner browser bundle. Round-trip tests use fit-file-parser as an independent oracle and assert lat/lon parity within 1e-4 deg and altitude within 0.5 m across short flat, alpine, multi-day, and single-point fixtures. Updates design.md decision #1: the original hand-rolled-encoder plan was justified largely by ESM friction in the Garmin SDK, but as of v21.202.0 the SDK is pure ESM with zero deps. Wrapping it saves us ~400 LOC of binary plumbing and ongoing maintenance against future FIT spec updates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a45a3808d9
commit
8ba5554a67
18 changed files with 384 additions and 18 deletions
|
|
@ -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 <https://developer.garmin.com/fit/cookbook/encoding-course-files/>.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue