trails/openspec/specs/wahoo-route-push/spec.md
Ullrich Schäfer 7cd785e937
Archive deepen-connected-services + sync spec deltas
Task 7.3: applies the MODIFIED + ADDED requirements from
changes/deepen-connected-services/specs/ into openspec/specs/:

- connected-services/spec.md:
  - MODIFIED: OAuth token storage (renamed sync_connections → connected_services
    with credential_kind discriminator + JSONB credentials shape).
  - ADDED: Capability seams for providers (Importer / RoutePusher /
    WebhookReceiver per ADR-0002, no unified SyncProvider).
  - ADDED: Centralized credential lifecycle via ConnectedServiceManager.

- wahoo-import/spec.md: Provider-agnostic framework rewritten to reference
  capability seams + per-provider manifest. Token refresh now goes through
  withFreshCredentials. Renames sync_connections → connected_services
  throughout.

- wahoo-route-push/spec.md: Renames sync_connections → connected_services.
  ADDED: RoutePusher capability seam — shape (service, route) →
  {remoteId, version} per ADR-0003; Wahoo workarounds stay inside the
  adapter.

Change directory moved to openspec/changes/archive/2026-05-08-deepen-connected-services/.

29/29 tasks complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:36:19 +02:00

11 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 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/<remote_id> 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,<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/<remote_id> (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/<remote_id> 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:<route_id> (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 { 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 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<last_pushed_version>) — 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:<route_id> 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