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>
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_connectionsrow withprovider = '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_connectionsrow - 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
geomis null or whosegpxis 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/routeswith 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_pushesrow is inserted withpushed_at = now(),remote_idset from Wahoo's response, anderror = 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_pushesrow is inserted withpushed_at = null,remote_id = null, anderrorpopulated 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_pushesrow 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_pushesrow withpushed_atset - 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_pushesrow yet, so the push proceeds normally - AND a new
sync_pushesrow is inserted for the new version
Scenario: Retry after a failed push
- WHEN a previous push attempt for this version failed (
pushed_atnull,errorpopulated) - THEN clicking "Send to Wahoo" again retries the push
- AND the existing
sync_pushesrow 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_idis set toroute:<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_scopesdoes not includeroutes_write - THEN the server redirects the user to Wahoo's authorization URL with the full updated scope list and a
statethat encodes{ return_to: <route_url>, push_after: true } - AND no Wahoo
/v1/routescall is attempted
Scenario: Push completes after re-auth
- WHEN the user returns from Wahoo's OAuth callback with
push_after = truein 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_pushesrow 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
Uint8Arrayis a valid FIT file that decodes viafit-file-parserto 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
altitudevalues 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
gpxToFitCoursethrows 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_pushesrow - 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_pushesrow haserrorset andpushed_atnull - THEN the page shows "Last attempt failed: " with a "Send to Wahoo" button still active