- authentication-methods: document completeAuth mode param ("redirect"|"json");
clarify add-passkey nudge (no dismiss mechanism, disappears on passkey add)
- journal-auth: session maxAge is 30 days; terms allow-list uses /legal/ prefix
matching (broader than fixed list of paths)
- session-notes: mark awareness isolation and UndoManager isolation as not yet
implemented (shared instances in current code)
- activity-feed: add fan-out scenario for visibility change to public
- explore: note that ?perPage is not yet implemented (hardcoded page size)
- multi-day-routes: add per-day GPX track split scenario (splitByDays option);
document overnight vs isDayBreak naming gap
- osm-poi-overlays: debounce is 800ms + 2000ms min interval (not 500ms);
retry is not automatic (fires on next viewport change)
- brouter-integration: rate limit corrected to 300/hour; add segment-cache
requirement (client caches per-pair segments)
- wahoo-route-push: OAuth state shape uses camelCase (returnTo, pushAfter
object) not snake_case with push_after boolean
- komoot-import: document noop adapter / ConnectedServiceManager bypass;
note four Komoot-specific routes that bypass the generic OAuth framework
- background-jobs: exponential backoff not wired (retryLimit only); add SIGINT
- connected-services: add revoked status; name ConnectionNotActiveError
- infrastructure: add INTEGRATION_SECRET and SENTRY_DSN to env var lists;
split secret decryption scenario by workflow (cd-apps vs cd-infra)
- secret-management: correct CD decryption — cd-apps only decrypts app.env;
cd-infra decrypts both
- transactional-emails: welcome email is async (pg-boss job); magic-link email
includes 6-digit numeric code
- journal-route-detail: websites are https: links (not mailto:); opening_hours
is also displayed
- local-dev-environment: add mobile app (Expo) dev commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
11 KiB
Markdown
152 lines
11 KiB
Markdown
## 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 `{ returnTo: <route_url>, pushAfter: { routeId: <id> } }` (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 `<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 <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 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
|
|
|
|
### 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
|