trails/openspec/specs/wahoo-route-push/spec.md
Ullrich Schäfer b9c559469a
Archive wahoo-route-push change
Move completed wahoo-route-push change to archive and sync delta specs:
update wahoo-import for routes_write scope, add new wahoo-route-push capability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:52:34 +02:00

7.7 KiB

Purpose

Push planned routes from the Journal to a connected Wahoo account as FIT Course files, so users can ride routes they planned in trails.cool on their Wahoo head unit.

Requirements

Requirement: Send to Wahoo action on route detail page

The Journal SHALL show a "Send to Wahoo" button on the route detail page when all of the following hold: the viewer owns the route, the viewer has a connected Wahoo account, and the route has stored geometry (routes.geom is non-null and routes.gpx is non-empty). The button SHALL trigger a server action that pushes the current route version to Wahoo.

Scenario: Owner with connected Wahoo and a route with geometry

  • WHEN the route owner loads a route detail page for a route that has geometry
  • AND the owner has a sync_connections row with provider = 'wahoo'
  • THEN a "Send to Wahoo" button is visible alongside the existing "Export GPX" action

Scenario: Owner without a connected Wahoo account

  • WHEN the route owner loads a route detail page
  • AND the owner has no Wahoo sync_connections row
  • THEN the "Send to Wahoo" button is not rendered

Scenario: Non-owner viewing the route

  • WHEN any visitor who is not the route owner loads the route detail page
  • THEN the "Send to Wahoo" button is not rendered, regardless of the viewer's own Wahoo connection

Scenario: Route without geometry

  • WHEN the route owner loads a route detail page for a route whose geom is null or whose gpx is empty
  • THEN the "Send to Wahoo" button is not rendered

Requirement: Server-side route push pipeline

The Journal SHALL convert the current route version to a FIT Course file and POST it to https://api.wahooligan.com/v1/routes with the user's stored Wahoo access token, recording the outcome in sync_pushes.

Scenario: Successful push

  • WHEN the route owner triggers the push action for a route version that has not been pushed before
  • THEN the server reads the route's GPX, runs gpxToFitCourse, base64-encodes the result, and POSTs it to /v1/routes with the required fields (external_id, provider_updated_at, name, workout_type_family_id, start_lat, start_lng, distance, ascent)
  • AND on a 2xx response a sync_pushes row is inserted with pushed_at = now(), remote_id set from Wahoo's response, and error = null
  • AND the user is shown a "Sent to Wahoo" confirmation

Scenario: Wahoo returns an error

  • WHEN Wahoo responds with a non-2xx status
  • THEN a sync_pushes row is inserted with pushed_at = null, remote_id = null, and error populated with the response body or status
  • AND the user is shown a generic "Sending to Wahoo failed — try again later" message
  • AND no exception leaks to the browser

Scenario: Route file omits records

  • WHEN the route's GPX has no track points (zero-segment route)
  • THEN the server returns HTTP 422 to the client and does not call Wahoo
  • AND no sync_pushes row is created

Requirement: Push idempotency per route version

Each combination of (user_id, route_id, route_version, provider) SHALL be pushed at most once successfully. Re-clicking the button for the same version SHALL be a no-op surfacing the existing remote id.

Scenario: Re-push of an already-pushed version

  • WHEN the route owner clicks "Send to Wahoo" on a version that already has a sync_pushes row with pushed_at set
  • THEN the server skips the Wahoo call and returns the existing remote_id
  • AND the UI shows "Already on Wahoo" with the timestamp from the existing row

Scenario: Push of a new version after editing

  • WHEN the route owner edits the route, creating a new version, and clicks "Send to Wahoo"
  • THEN the new version has no sync_pushes row yet, so the push proceeds normally
  • AND a new sync_pushes row is inserted for the new version

Scenario: Retry after a failed push

  • WHEN a previous push attempt for this version failed (pushed_at null, error populated)
  • THEN clicking "Send to Wahoo" again retries the push
  • AND the existing sync_pushes row is updated in place (no duplicate row)

Requirement: Stable external_id per route version

The external_id field sent to Wahoo SHALL be deterministic per (route_id, route_version) so that retries and accidental races do not create duplicate routes on Wahoo's side.

Scenario: External id is deterministic

  • WHEN the server constructs the Wahoo payload for a route version
  • THEN external_id is set to route:<route_id>:v<route_version>
  • AND identical calls for the same version produce the same external_id

Requirement: Re-auth flow when routes_write scope is missing

The Journal SHALL detect when a connected Wahoo account lacks the routes_write scope before calling Wahoo, redirect the user through OAuth to grant it, and resume the push automatically after the user returns.

Scenario: Existing connection lacks routes_write

  • WHEN the route owner clicks "Send to Wahoo"
  • AND the user's sync_connections.granted_scopes does not include routes_write
  • THEN the server redirects the user to Wahoo's authorization URL with the full updated scope list and a state that encodes { return_to: <route_url>, push_after: true }
  • AND no Wahoo /v1/routes call is attempted

Scenario: Push completes after re-auth

  • WHEN the user returns from Wahoo's OAuth callback with push_after = true in the state
  • THEN the connection is updated with the new scopes
  • AND the push action runs automatically against the route encoded in return_to
  • AND the user lands back on the route detail page with the "Sent to Wahoo" confirmation visible

Scenario: User declines the new scope

  • WHEN the user reaches Wahoo's authorization page and clicks "Deny"
  • THEN the user is redirected back to the route detail page with an inline notice "Sending to Wahoo needs route permission — please reconnect your account in Settings"
  • AND no sync_pushes row is created

Requirement: GPX to FIT Course conversion

The system SHALL provide a gpxToFitCourse function in the @trails-cool/fit package that converts a GPX string to a FIT Course binary suitable for POST /v1/routes.

Scenario: GPX with track points produces a valid FIT Course

  • WHEN gpxToFitCourse({ gpx, name }) is called with a GPX string containing one or more track points
  • THEN the returned Uint8Array is a valid FIT file that decodes via fit-file-parser to a Course with the same number of records as the input had track points
  • AND each record's lat/lon round-trips to within 1e-5 degrees of the source

Scenario: GPX with elevation data preserves elevation

  • WHEN the input GPX track points include <ele> values
  • THEN the encoded FIT records carry altitude values that round-trip to within 0.5 m of the source

Scenario: GPX without track points is rejected

  • WHEN the input GPX has no track points
  • THEN gpxToFitCourse throws an error and produces no output

Requirement: Push status surfaced on the route detail page

The route detail page SHALL show whether the current route version has been pushed to Wahoo, with timestamp.

Scenario: Already-pushed route shows status

  • WHEN the route owner views a route version with a successful sync_pushes row
  • THEN the page shows "Sent to Wahoo on " near the action buttons
  • AND the "Send to Wahoo" button is replaced with disabled "Already on Wahoo" text

Scenario: Push failed previously shows retry affordance

  • WHEN the route owner views a route version whose latest sync_pushes row has error set and pushed_at null
  • THEN the page shows "Last attempt failed: " with a "Send to Wahoo" button still active