From b9c559469a4e71ec8d39a2b088abdf57eb89d728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 1 May 2026 07:52:34 +0200 Subject: [PATCH] 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 --- .../.openspec.yaml | 0 .../2026-05-01-wahoo-route-push}/design.md | 0 .../2026-05-01-wahoo-route-push}/proposal.md | 0 .../specs/wahoo-import/spec.md | 0 .../specs/wahoo-route-push/spec.md | 0 .../2026-05-01-wahoo-route-push}/tasks.md | 0 openspec/specs/wahoo-import/spec.md | 8 +- openspec/specs/wahoo-route-push/spec.md | 120 ++++++++++++++++++ 8 files changed, 127 insertions(+), 1 deletion(-) rename openspec/changes/{wahoo-route-push => archive/2026-05-01-wahoo-route-push}/.openspec.yaml (100%) rename openspec/changes/{wahoo-route-push => archive/2026-05-01-wahoo-route-push}/design.md (100%) rename openspec/changes/{wahoo-route-push => archive/2026-05-01-wahoo-route-push}/proposal.md (100%) rename openspec/changes/{wahoo-route-push => archive/2026-05-01-wahoo-route-push}/specs/wahoo-import/spec.md (100%) rename openspec/changes/{wahoo-route-push => archive/2026-05-01-wahoo-route-push}/specs/wahoo-route-push/spec.md (100%) rename openspec/changes/{wahoo-route-push => archive/2026-05-01-wahoo-route-push}/tasks.md (100%) create mode 100644 openspec/specs/wahoo-route-push/spec.md diff --git a/openspec/changes/wahoo-route-push/.openspec.yaml b/openspec/changes/archive/2026-05-01-wahoo-route-push/.openspec.yaml similarity index 100% rename from openspec/changes/wahoo-route-push/.openspec.yaml rename to openspec/changes/archive/2026-05-01-wahoo-route-push/.openspec.yaml diff --git a/openspec/changes/wahoo-route-push/design.md b/openspec/changes/archive/2026-05-01-wahoo-route-push/design.md similarity index 100% rename from openspec/changes/wahoo-route-push/design.md rename to openspec/changes/archive/2026-05-01-wahoo-route-push/design.md diff --git a/openspec/changes/wahoo-route-push/proposal.md b/openspec/changes/archive/2026-05-01-wahoo-route-push/proposal.md similarity index 100% rename from openspec/changes/wahoo-route-push/proposal.md rename to openspec/changes/archive/2026-05-01-wahoo-route-push/proposal.md diff --git a/openspec/changes/wahoo-route-push/specs/wahoo-import/spec.md b/openspec/changes/archive/2026-05-01-wahoo-route-push/specs/wahoo-import/spec.md similarity index 100% rename from openspec/changes/wahoo-route-push/specs/wahoo-import/spec.md rename to openspec/changes/archive/2026-05-01-wahoo-route-push/specs/wahoo-import/spec.md diff --git a/openspec/changes/wahoo-route-push/specs/wahoo-route-push/spec.md b/openspec/changes/archive/2026-05-01-wahoo-route-push/specs/wahoo-route-push/spec.md similarity index 100% rename from openspec/changes/wahoo-route-push/specs/wahoo-route-push/spec.md rename to openspec/changes/archive/2026-05-01-wahoo-route-push/specs/wahoo-route-push/spec.md diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/archive/2026-05-01-wahoo-route-push/tasks.md similarity index 100% rename from openspec/changes/wahoo-route-push/tasks.md rename to openspec/changes/archive/2026-05-01-wahoo-route-push/tasks.md diff --git a/openspec/specs/wahoo-import/spec.md b/openspec/specs/wahoo-import/spec.md index 881905f..0053c3b 100644 --- a/openspec/specs/wahoo-import/spec.md +++ b/openspec/specs/wahoo-import/spec.md @@ -18,9 +18,10 @@ Users SHALL be able to connect their Wahoo account via OAuth2. #### Scenario: Connect Wahoo - **WHEN** a user clicks "Connect Wahoo" in journal settings -- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data` +- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write` - **AND** after granting permission, redirected back to the journal - **AND** access and refresh tokens are stored in `sync_connections` +- **AND** the granted scopes are recorded in `sync_connections.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo #### Scenario: Disconnect Wahoo - **WHEN** a user clicks "Disconnect" next to their Wahoo connection @@ -30,6 +31,11 @@ Users SHALL be able to connect their Wahoo account via OAuth2. - **WHEN** a Wahoo API call fails with an expired token - **THEN** the refresh token is used to obtain a new access token automatically +#### Scenario: Existing connection without routes_write +- **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route) +- **THEN** the system detects the missing scope from `sync_connections.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope +- **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work + ### Requirement: Webhook-based automatic sync New Wahoo workouts SHALL be automatically imported when they complete. diff --git a/openspec/specs/wahoo-route-push/spec.md b/openspec/specs/wahoo-route-push/spec.md new file mode 100644 index 0000000..6d48bd5 --- /dev/null +++ b/openspec/specs/wahoo-route-push/spec.md @@ -0,0 +1,120 @@ +## 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::v` +- **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: , 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 `` 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