trails/openspec/changes/wahoo-route-push/proposal.md
Ullrich Schäfer efe70c2c3a Propose wahoo-route-push: send planned routes to a user's Wahoo account
Wahoo's Cloud API exposes POST /v1/routes (scope routes_write), which
syncs to the Wahoo App and to ELEMNT/BOLT/ROAM head units. This change
proposes the push pipeline: GPX -> FIT Course (new @trails-cool/fit
package) -> Wahoo, with sync_pushes for idempotency, an explicit
re-auth flow for the new scope, and a "Send to Wahoo" affordance on
the route detail page. Read-only Wahoo import is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:38:14 +02:00

3.4 KiB

Why

The existing Wahoo integration is read-only: we pull completed workouts back into the Journal as activities. Users who plan a route in trails.cool today still have to email themselves a GPX, open the Wahoo app, and import it manually before the route reaches their bike computer. Wahoo's Cloud API exposes POST /v1/routes (scope routes_write) — verified at https://cloud-api.wahooligan.com/ — which lets us push a route directly so it appears on the user's ELEMNT/BOLT/ROAM. Closing this loop is the single biggest "why isn't this on my head unit?" friction we hear from cyclists testing the Planner.

What Changes

  • New "Send to Wahoo" action on the route detail page in the Journal, visible only to the owner when their Wahoo account is connected.
  • New server-side route push pipeline: GPX → FIT Course binary → POST https://api.wahooligan.com/v1/routes.
  • New shared package @trails-cool/fit that wraps a JS FIT encoder and exports gpxToFitCourse(gpx: string): Uint8Array. The Journal owns the API call; the package is provider-agnostic.
  • New routes_write OAuth scope added to the Wahoo connect flow. Existing connected users will be prompted to re-authorize the first time they push a route (no silent scope upgrade).
  • New sync_pushes table (mirrors sync_imports) so we can show "Sent to Wahoo at
  • Extend SyncProvider interface with an optional pushRoute(connection, route) method. Providers that don't support push (none today) leave it undefined; the UI hides the button.

Capabilities

New Capabilities

  • wahoo-route-push: Push a Journal route to a connected Wahoo account so it syncs to the user's bike computer. Covers the UI affordance, GPX→FIT Course conversion, the POST /v1/routes call, idempotency via sync_pushes, and re-auth handling for the new scope.

Modified Capabilities

  • wahoo-import: The "Connect Wahoo" scenario currently lists scopes workouts_read, user_read, offline_data. It must also request routes_write, and existing connections without that scope must be re-prompted on first push.

Impact

  • Code:
    • apps/journal/app/lib/sync/providers/wahoo.ts — add routes_write scope, implement pushRoute, surface scope-mismatch errors.
    • apps/journal/app/lib/sync/types.ts — extend SyncProvider interface.
    • apps/journal/app/routes/ — new /api/sync/push/wahoo/:routeId action; "Send to Wahoo" button on the route detail page.
    • apps/journal/db/schema.ts — new sync_pushes table + Drizzle migration.
    • 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).
  • 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).