## 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 `connected_services` 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 `connected_services` 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 send it to Wahoo, recording the outcome in `sync_pushes`. When no successful push exists for the `(user_id, route_id, provider='wahoo')` tuple, the request is `POST https://api.wahooligan.com/v1/routes`. When a successful push exists with a non-null `remote_id`, the request is `PUT https://api.wahooligan.com/v1/routes/` so subsequent pushes update the existing Wahoo route in place rather than creating duplicates. If a PUT returns 404 (the user deleted the Wahoo route on their side), the server SHALL fall back to POST and overwrite `remote_id` with the response. The body shape is identical for POST and PUT and matches the existing field set (`external_id`, `provider_updated_at`, `name`, `workout_type_family_id`, `start_lat`, `start_lng`, `distance`, `ascent`, `route[file]` data URI). #### Scenario: First push of a route - **WHEN** the route owner triggers the push action and no `sync_pushes` row exists for `(user, route, wahoo)` - **THEN** the server reads the route's GPX, runs `gpxToFitCourse`, encodes the result as `data:application/vnd.fit;base64,`, and POSTs it to `/v1/routes` - **AND** on a 2xx response, a `sync_pushes` row is inserted with `pushed_at = now()`, `remote_id` set from Wahoo's response, `last_pushed_version` set to the local route version, and `error = null` - **AND** the user is shown a "Sent to Wahoo" confirmation #### Scenario: Re-push after editing the route - **WHEN** the route owner edits a previously-pushed route, then triggers the push action - **AND** a `sync_pushes` row exists for `(user, route, wahoo)` with non-null `remote_id` - **THEN** the server PUTs the new FIT and metadata to `/v1/routes/` (not POST) - **AND** on a 2xx response, the existing `sync_pushes` row is updated in place: `pushed_at = now()`, `last_pushed_version` advanced to the new local version, `error = null` - **AND** the Wahoo route id remains the same (no duplicate route created on Wahoo's side) #### Scenario: PUT against a deleted Wahoo route falls back to POST - **WHEN** the server PUTs to `/v1/routes/` and Wahoo returns 404 - **THEN** the server retries the request as POST `/v1/routes` - **AND** the existing `sync_pushes` row is updated with the new `remote_id` from the POST response #### Scenario: Wahoo returns an error - **WHEN** Wahoo responds with a non-2xx status that is not the 404 fallback - **THEN** the `sync_pushes` row is created or updated in place with `error` populated and `pushed_at` unchanged - **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 or modified ### Requirement: Push idempotency per route Each `(user_id, route_id, provider)` SHALL map to at most one `sync_pushes` row. Re-clicking the button when the local version equals `last_pushed_version` and the row's `pushed_at` is non-null SHALL be a no-op surfacing the existing remote id; clicking after editing (local version > `last_pushed_version`) SHALL trigger an update via PUT. #### Scenario: Re-push of an already-pushed unchanged route - **WHEN** the route owner clicks "Send to Wahoo" and the existing `sync_pushes` row has `last_pushed_version` equal to the current route version and a non-null `pushed_at` - **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 row #### Scenario: Push after editing the route (new version) - **WHEN** the route owner edits the route (incrementing the local version) and clicks "Send to Wahoo" - **THEN** the server takes the PUT path against the existing `remote_id` - **AND** exactly one `sync_pushes` row exists for `(user, route, wahoo)` after the operation completes #### Scenario: Retry after a failed push - **WHEN** the existing `sync_pushes` row has `pushed_at = null` and `error` populated - **THEN** clicking "Send to Wahoo" retries — POST if `remote_id` is null, PUT if `remote_id` is non-null - **AND** the existing row is updated in place (no duplicate row) ### Requirement: Stable external_id per route The `external_id` field sent to Wahoo SHALL be deterministic per `(route_id)` so that the value identifies the *logical* route, not a specific edit of it. This makes the source-of-truth identifier stable across re-pushes. #### Scenario: External id is deterministic - **WHEN** the server constructs the Wahoo payload for any version of a route - **THEN** `external_id` is set to `route:` (no version suffix) - **AND** subsequent pushes of the same route — including PUTs after edits — send 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 `connected_services.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 `{ returnTo: , pushAfter: { routeId: } }` (camelCase; `pushAfter` is an object, not a boolean) - **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 `` 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 route has been pushed to Wahoo and whether the local version is newer than what Wahoo currently has. #### Scenario: Local version matches Wahoo's - **WHEN** the route owner views a route whose `sync_pushes` row has `last_pushed_version` equal to the current local version and a non-null `pushed_at` - **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: Local version is newer than Wahoo's - **WHEN** the route owner views a route whose `last_pushed_version` is less than the current local version - **THEN** the page shows "On Wahoo (v) — local version is newer" - **AND** the action button reads "Send updated version" and triggers the same push action (which will PUT) #### Scenario: Push failed previously shows retry affordance - **WHEN** the route owner views a route whose `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 ### Requirement: `RoutePusher` capability seam The system SHALL expose `RoutePusher` as the capability seam through which any provider pushes routes. The seam shape is `pushRoute(service, route) → {remoteId, version}`. Provider-specific concerns — file format conversion (e.g. FIT Course), `external_id` conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the `RoutePusher` interface. #### Scenario: Wahoo pusher implements the seam without leaking workarounds - **WHEN** the route push action invokes the Wahoo `RoutePusher` - **THEN** the seam is called as `pushRoute(connectedService, route)` and returns `{remoteId, version}` - **AND** the FIT-Course conversion, the `external_id = route:` convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter - **AND** callers do not pass FIT data or Wahoo-specific fields across the seam #### Scenario: Future pusher reuses the same seam - **WHEN** a developer adds a second `RoutePusher` (e.g. Garmin) - **THEN** they implement `pushRoute(service, route) → {remoteId, version}` with the same shape - **AND** the route push action invokes it identically to the Wahoo pusher - **AND** their format conversion and HTTP recovery live inside the adapter