Archive wahoo-route-update

Fold the wahoo-route-update delta into openspec/specs/wahoo-route-push/spec.md
(POST→PUT logic with 404 fallback, stable external_id, push-status UI) and
move the change directory to openspec/changes/archive/. Task 4.3 (Playwright
E2E) skipped — contract is fully covered by unit/integration tests in
wahoo.test.ts and pushes.server.test.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-03 21:36:15 +02:00
parent a62b249aed
commit 6f6010cb51
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 48 additions and 31 deletions

View file

@ -26,50 +26,62 @@ The Journal SHALL show a "Send to Wahoo" button on the route detail page when al
- **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`.
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: 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`
#### 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
- **THEN** a `sync_pushes` row is inserted with `pushed_at = null`, `remote_id = null`, and `error` populated with the response body or status
- **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
- **AND** no `sync_pushes` row is created or modified
### 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.
### 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 version
- **WHEN** the route owner clicks "Send to Wahoo" on a version that already has a `sync_pushes` row with `pushed_at` set
#### 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 existing row
- **AND** the UI shows "Already on Wahoo" with the timestamp from the 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: 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** 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)
- **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 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.
### 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 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`
- **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.
@ -108,13 +120,18 @@ The system SHALL provide a `gpxToFitCourse` function in the `@trails-cool/fit` p
- **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.
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: Already-pushed route shows status
- **WHEN** the route owner views a route version with a successful `sync_pushes` row
#### 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 <date>" 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 version whose latest `sync_pushes` row has `error` set and `pushed_at` null
- **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: <short error>" with a "Send to Wahoo" button still active