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