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>
7.8 KiB
7.8 KiB
1. FIT Course encoder package
- 1.1 Scaffold
packages/fit/(package.json, tsconfig, vitest config) following the pattern ofpackages/gpx/ - 1.2 Add the package to
pnpm-workspace.yamland reference it fromapps/journal/package.json - 1.3 Add
@garmin/fitsdkdependency topackages/fit/package.jsonand a small ambientsrc/fitsdk.d.tsdeclaring theEncoder,Stream, andProfile.MesgNumshapes we use (the SDK ships no types) - 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), andrecord(position_lat, position_long, altitude, distance) - 1.5 Implement
gpxToFitCourse({ gpx, name, description?, sport? }): parse GPX via@trails-cool/gpx, compute distance and ascent, drive the SDK encoder, returnUint8Array - 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 testandpnpm typecheckruns (should be automatic via Turborepo)
2. Database schema for push tracking
- 2.1 Add
sync_pushestable topackages/db/src/schema/journal.tswith columns from design.md §3 and a unique index on(user_id, route_id, route_version, provider) - 2.2 Add a
granted_scopes text[]column tosync_connectionsin the same schema file, defaulting to an empty array, and backfill existing rows with the legacy scopes (workouts_read,user_read,offline_data) - 2.3 Generate the Drizzle migration via
pnpm --filter @trails-cool/db db:generate(migration files land inpackages/db/migrations/) - 2.4 Apply locally via
pnpm db:pushand verify withpnpm db:studio
3. Wahoo provider scope upgrade
- 3.1 Add
routes_writeto the Wahoo scopes array inapps/journal/app/lib/sync/providers/wahoo.ts:14 - 3.2 Persist
granted_scopeson thesync_connectionsrow atexchangeCodetime. Wahoo's token endpoint does not return ascopefield and grants scopes all-or-nothing, so record the requested scope set as granted. The scope-mismatch detection in §5.2 is therefore a comparison against our own constant, not against provider-reported state — that's intentional and only needs to flag pre-routes_writeconnections. - 3.3 Implement
pushRoute(connection, { gpx, name, description, externalId })on the Wahoo provider: base64-encode the FIT, build the form-encoded body, POST/v1/routes, return{ remoteId }on success or throw a typed error on failure (token expired, scope missing, validation error, rate limit, generic) - 3.4 Wire token-refresh-on-401 through the new push call (reuse the existing
withFreshTokenhelper or equivalent)
4. Sync provider interface extension
- 4.1 Add an optional
pushRoute?: (connection, payload) => Promise<{ remoteId: string }>method to theSyncProviderinterface inapps/journal/app/lib/sync/types.ts - 4.2 Define the
PushRoutePayloadandPushRouteResulttypes alongside it - 4.3 Add a
providerSupportsPush(provider)helper for use in loaders/UI
5. Push action route and idempotency layer
- 5.1 Add an action route at
/api/sync/push/wahoo/:routeIdthat resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push - 5.2 Before calling Wahoo, check
sync_connections.granted_scopesforroutes_write; if missing, redirect togetAuthUrlwith state{ return_to, push_after } - 5.3 Look up an existing
sync_pushesrow for(user_id, route_id, route_version, 'wahoo')— ifpushed_atis set, return the existingremote_idwithout calling Wahoo - 5.4 On a fresh push, build the payload (
external_id = "route:<routeId>:v<version>", current timestamp, route name/description, start lat/lng, computed distance and ascent), callprovider.pushRoute, and write the result tosync_pushes(insert on success, update existing row on retry of a failed attempt) - 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry
6. OAuth callback resumes pending push
- 6.1 Extend the OAuth callback handler at
/api/sync/callback/wahooto parsestate.push_afterandstate.return_to - 6.2 If
push_after, after persisting the new tokens and scopes, run the push pipeline server-side and redirect toreturn_towith a success or failure flash message - 6.3 If the user denied the new scope (callback arrives with
?error=access_denied), redirect toreturn_towith the "needs route permission" notice
7. Route detail page UI
- 7.1 In the route detail loader, include the latest
sync_pushesrow for the current version and the user's Wahoo connection presence + scopes - 7.2 Render the "Send to Wahoo" button next to the existing Export GPX action when conditions in spec §1 are met
- 7.3 Render the post-push status: "Sent to Wahoo on " replacing the button when a successful push exists
- 7.4 Render the failed-push state: "Last attempt failed: " with the button still active
- 7.5 Add the button copy "This sends your route to Wahoo" as a tooltip or helper text near the button (privacy disclosure at the action point)
- 7.6 Add i18n strings for all new UI copy (English + German) per the i18n convention in CLAUDE.md
8. Privacy manifest
- 8.1 Update
docs/privacy.md(or whichever file holds the privacy manifest) to declare that route geometry, name, and description are sent to Wahoo when the user clicks "Send to Wahoo" - 8.2 Cross-link the privacy entry from the proposal and from this change's README in
openspec/changes/wahoo-route-push/
9. Tests
- 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 - 9.5 E2E test for the re-auth flow: user without
routes_writeclicks Send to Wahoo, redirects to OAuth (mocked), returns, push completes, status renders
10. Manual verification before merge
- 10.1 Smoke test against Wahoo's real API in a dev sandbox: push a real route, confirm it appears in the Wahoo App and on a physical ELEMNT/BOLT/ROAM
- 10.2 Verify
provider_updated_atandexternal_idsemantics: push v1, edit and push v2, confirm both appear as separate routes on Wahoo - 10.3 Verify error path: revoke
routes_writefrom the dev account in Wahoo's UI, click Send to Wahoo, confirm the re-auth flow runs and completes the push - 10.4 Verify the privacy manifest entry is reachable from the in-app privacy disclosure
11. Release
- 11.1 Run
pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e - 11.2 Open PR with the change summary linking to
openspec/changes/wahoo-route-push/proposal.md - 11.3 After merge, monitor
sync_pushes.errorfor the first week; if a recurring failure mode shows up, file a follow-up - 11.4 Run
/opsx:archive wahoo-route-pushonce the change is live and confirmed