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 <noreply@anthropic.com>
This commit is contained in:
parent
405f434d1a
commit
b9c559469a
8 changed files with 127 additions and 1 deletions
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-28
|
||||
118
openspec/changes/archive/2026-05-01-wahoo-route-push/design.md
Normal file
118
openspec/changes/archive/2026-05-01-wahoo-route-push/design.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
## Context
|
||||
|
||||
The Journal already runs a one-way sync with Wahoo: webhooks deliver `workout_summary` events, we download the FIT, decode it with `fit-file-parser`, and store a Journal activity. The Planner emits GPX as the route exchange format and the Journal stores route geometry as PostGIS LineStrings (see `apps/journal/app/lib/sync/providers/wahoo.ts`, `packages/db/src/schema/journal.ts`).
|
||||
|
||||
Wahoo's `POST /v1/routes` accepts a base64-encoded FIT **Course** file (different message profile from a workout/activity) plus `external_id`, `provider_updated_at`, `name`, `workout_type_family_id`, `start_lat`, `start_lng`, `distance`, `ascent`, optionally `description`, `descent`, `filename`. The OAuth scope `routes_write` is required, which the existing connection does not hold. Wahoo confirms routes uploaded this way reach the Wahoo App and ELEMNT/BOLT/ROAM head units; only the legacy ELEMNT App does not see them.
|
||||
|
||||
There is no FIT *encoder* in the repo today — we only decode. FIT Course encoding is the one piece of real engineering in this change; everything else is plumbing on top of the existing sync framework.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- One-tap "Send to Wahoo" on the route detail page that lands the route on the user's bike computer.
|
||||
- A reusable `@trails-cool/fit` package so future providers (Garmin Connect, Hammerhead) can share the encoder.
|
||||
- Idempotent server-side pipeline that records what was pushed, detects re-pushes, and surfaces a clear "Sent to Wahoo" status in the UI.
|
||||
- Clean re-auth flow when an existing user lacks `routes_write` — no silent scope upgrades, no broken pushes.
|
||||
|
||||
**Non-Goals:**
|
||||
- Bulk push ("send all my routes") — out of scope until single-route push is solid.
|
||||
- Auto-push on route save — push is always an explicit user action.
|
||||
- Pushing route updates after the first send (`PUT /v1/routes/:id`) — first send wins; updates require a fresh push from the user. We can revisit once we have telemetry on whether users actually edit routes after pushing.
|
||||
- Provider-agnostic UI — the button reads "Send to Wahoo". When a second push provider lands we'll generalize.
|
||||
- Cue sheets / turn-by-turn instructions in the FIT Course — Wahoo head units generate these from the geometry on-device.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Use Garmin's official `@garmin/fitsdk` package server-side
|
||||
|
||||
**Decision**: Depend on `@garmin/fitsdk` (the renamed, current package — formerly published as `@garmin/fitsdk-javascript`) and wrap it in `packages/fit/` with a single `gpxToFitCourse(...)` export. Encoding runs server-side in the Journal action route only — the SDK does not ship to the planner browser bundle.
|
||||
|
||||
**Why** (revised after re-evaluating the SDK as of 2026-04):
|
||||
- The SDK is now **pure ESM** (`"type": "module"`, zero deps). The earlier ESM/Vite friction is gone.
|
||||
- Actively maintained by Garmin, published quarterly in lockstep with the FIT profile (latest 21.202.0, Apr 2026).
|
||||
- First-class Course encoder API (`new Encoder()` + `writeMesg(...)` + `close()` → `Uint8Array`) with an official cookbook at <https://developer.garmin.com/fit/cookbook/encoding-course-files/>.
|
||||
- The ~1 MB unpacked size is real but irrelevant: encoding lives behind `/api/sync/push/wahoo/:routeId` (Node 20). Nothing in the planner imports `@trails-cool/fit`.
|
||||
- Avoids ~400 LOC of binary plumbing (file header, definition messages, data messages, CRC-16) that we'd otherwise own and maintain against future FIT spec updates.
|
||||
|
||||
**Round-trip oracle**: `fit-file-parser` (already a dep on the import path) decodes every encoded fixture in tests and asserts track-point parity with the source GPX. Same correctness story as before — just with less code in our repo.
|
||||
|
||||
**Cost**: TypeScript types aren't shipped, so `packages/fit/` includes a small ambient `fitsdk.d.ts` (~30 lines covering `Encoder`, `Stream`, `Profile.MesgNum`).
|
||||
|
||||
**Alternatives considered**: hand-rolled encoder (rejected: 400 LOC of binary format we'd own forever, plus ongoing maintenance against FIT spec updates — net loss now that the SDK's ESM/maintenance objections are resolved); `fit-file-writer` (rejected: appears unpublished from npm registry); shelling out to Python `fit-tool` (rejected: new runtime dependency on the deploy host).
|
||||
|
||||
### 2. New `packages/fit/` package, GPX-shaped public API
|
||||
|
||||
**Decision**: Public API of `@trails-cool/fit` is one function:
|
||||
|
||||
```ts
|
||||
export function gpxToFitCourse(input: {
|
||||
gpx: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sport?: "cycling" | "running" | "hiking";
|
||||
}): Uint8Array;
|
||||
```
|
||||
|
||||
**Why**: Keeps the boundary at the format level, not at internal FIT message records. Future providers (Garmin) will accept the same FIT Course bytes; only the upload call differs. The package is provider-agnostic by construction.
|
||||
|
||||
### 3. Idempotency keyed on `(user_id, route_id, route_version, provider)`
|
||||
|
||||
**Decision**: New `journal.sync_pushes` table:
|
||||
|
||||
```
|
||||
sync_pushes (
|
||||
id text pk,
|
||||
user_id text not null references users(id),
|
||||
route_id text not null references routes(id),
|
||||
route_version int not null,
|
||||
provider text not null, -- 'wahoo'
|
||||
external_id text not null, -- stable per (route, version)
|
||||
remote_id text, -- Wahoo's route id, populated on success
|
||||
pushed_at timestamptz,
|
||||
error text, -- last error if push failed
|
||||
unique (user_id, route_id, route_version, provider)
|
||||
)
|
||||
```
|
||||
|
||||
The `external_id` we send to Wahoo is `route:<route_id>:v<version>` — deterministic, so two attempts to push the same route version produce the same `external_id` and Wahoo's de-dup applies if our own check ever races. The user-facing rule: each *version* of a route can be pushed once per provider. Editing the route appends a new row to `route_versions` (existing behavior in `route-management`) and unlocks a fresh push.
|
||||
|
||||
**What "current version" means**: The `routes` table has no `version` column; versions live in the existing `route_versions` table keyed by `(route_id, version)`. At push time we resolve the current version as `MAX(route_versions.version) WHERE route_id = ?` and use that integer for both the `sync_pushes.route_version` column and the `external_id` suffix. The push reads the GPX from that `route_versions` row, not from `routes.gpx`, so the bytes we send are exactly the snapshot the user is looking at.
|
||||
|
||||
### 4. Re-auth on scope upgrade is explicit, not silent
|
||||
|
||||
**Decision**: The first time a user with a pre-`routes_write` connection clicks "Send to Wahoo", the action route detects the missing scope (we store `granted_scopes` on the connection) and redirects through `getAuthUrl` again with the full new scope set, preserving a `?return_to=<route_url>&push_after=1` query param. After re-auth, the route action runs the push automatically.
|
||||
|
||||
**Why**: A silent scope upgrade isn't possible with OAuth — providers always re-prompt for new scopes — but we want the UX to feel like one click. Storing `granted_scopes` lets us detect the mismatch *before* hitting Wahoo and getting a 403, which is faster and gives us a clean redirect target.
|
||||
|
||||
**Alternative considered**: Re-prompt all existing users at next login. Rejected: too noisy for users who never push routes.
|
||||
|
||||
### 5. Privacy: route name + description + geometry leave the system on push
|
||||
|
||||
**Decision**: Update `docs/privacy.md` to state that when a user clicks "Send to Wahoo", the route's geometry, name, and description are transmitted to Wahoo and become subject to Wahoo's privacy policy. The push button copy includes "This sends your route to Wahoo" so the disclosure is at the action point, not buried in settings.
|
||||
|
||||
**Why**: Matches the privacy-first principle in CLAUDE.md. The push is opt-in per route, so no surprise data egress.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **FIT Course encoder bugs send unusable files to bike computers** → Mitigation: round-trip every encoded file through `fit-file-parser` in tests and assert track-point parity with the input GPX. Add a fixture set of real-world routes (short, long, with/without elevation, multi-day) to `packages/fit/__fixtures__/` and snapshot the round-trip output. Manual smoke test on at least one ELEMNT before merging.
|
||||
- **Wahoo API rate limits unknown** → Mitigation: log every push outcome to `sync_pushes.error`. If we observe rate limiting in dev, add a per-user 1-push-per-second throttle. Defer until observed.
|
||||
- **Routes without GPS geometry can't be pushed** (e.g. a route created from a handwritten description) → Mitigation: button is hidden when `routes.geom` is null or `routes.gpx` is empty; action route 422s with a clear message if it slips through.
|
||||
- **Wahoo deletes a pushed route on their side** → We don't reconcile. Re-pushing requires editing the route to bump the version. Acceptable for v1; revisit if it's a real complaint.
|
||||
- **`routes_write` scope rejection by Wahoo for new OAuth apps** → Wahoo's docs don't gate this scope behind a partner agreement, but if they do reject our app, we'd be stuck. Mitigation: confirm via a test push from a dev sandbox before announcing the feature externally.
|
||||
- **GPX-only routes lose Planner-specific metadata** (e.g. routing profile, no-go avoidance) → Acceptable; FIT Course only carries geometry. Wahoo head unit does its own re-routing if the rider deviates.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Ship `@trails-cool/fit` with no consumer; verify CI passes the round-trip tests.
|
||||
2. Apply the `sync_pushes` migration (additive — no risk to existing rows).
|
||||
3. Update Wahoo provider scopes and ship the re-auth detection. No behavioral change for users who never click "Send to Wahoo".
|
||||
4. Ship the action route + UI button. Feature is gated only by the user clicking it.
|
||||
5. After 2 weeks in production, review `sync_pushes.error` distribution, decide whether to expose the bulk push or auto-push affordances.
|
||||
|
||||
No rollback drama — the change is additive (new column, new package, new route). If `gpxToFitCourse` turns out to be broken, hide the button (one-line UI flag) while we fix the encoder.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Wahoo's `workout_type_family_id` mapping: 0 is "Cycling" per their docs, but we should confirm the running/hiking values before exposing them. For v1 we hardcode cycling.
|
||||
- Does Wahoo's `provider_updated_at` need to monotonically increase per `external_id`? If so, our deterministic per-version `external_id` might cause issues if a user pushes v2 of a route created earlier the same second. Test before launch.
|
||||
- Should the `description` field include a "Routed by trails.cool — <route URL>" footer? Nice for attribution but invades user content. Default to no footer for v1.
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
## Why
|
||||
|
||||
The existing Wahoo integration is read-only: we pull completed workouts back into the Journal as activities. Users who plan a route in trails.cool today still have to email themselves a GPX, open the Wahoo app, and import it manually before the route reaches their bike computer. Wahoo's Cloud API exposes `POST /v1/routes` (scope `routes_write`) — verified at <https://cloud-api.wahooligan.com/> — which lets us push a route directly so it appears on the user's ELEMNT/BOLT/ROAM. Closing this loop is the single biggest "why isn't this on my head unit?" friction we hear from cyclists testing the Planner.
|
||||
|
||||
## What Changes
|
||||
|
||||
- New "Send to Wahoo" action on the route detail page in the Journal, visible only to the owner when their Wahoo account is connected.
|
||||
- New server-side route push pipeline: GPX → FIT Course binary → `POST https://api.wahooligan.com/v1/routes`.
|
||||
- New shared package `@trails-cool/fit` that wraps a JS FIT encoder and exports `gpxToFitCourse(gpx: string): Uint8Array`. The Journal owns the API call; the package is provider-agnostic.
|
||||
- New `routes_write` OAuth scope added to the Wahoo connect flow. Existing connected users will be prompted to re-authorize the first time they push a route (no silent scope upgrade).
|
||||
- New `sync_pushes` table (mirrors `sync_imports`) so we can show "Sent to Wahoo at <time>" on the route detail page and avoid re-pushing identical content.
|
||||
- Extend `SyncProvider` interface with an optional `pushRoute(connection, route)` method. Providers that don't support push (none today) leave it undefined; the UI hides the button.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `wahoo-route-push`: Push a Journal route to a connected Wahoo account so it syncs to the user's bike computer. Covers the UI affordance, GPX→FIT Course conversion, the `POST /v1/routes` call, idempotency via `sync_pushes`, and re-auth handling for the new scope.
|
||||
|
||||
### Modified Capabilities
|
||||
- `wahoo-import`: The "Connect Wahoo" scenario currently lists scopes `workouts_read`, `user_read`, `offline_data`. It must also request `routes_write`, and existing connections without that scope must be re-prompted on first push.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**:
|
||||
- `apps/journal/app/lib/sync/providers/wahoo.ts` — add `routes_write` scope, implement `pushRoute`, surface scope-mismatch errors.
|
||||
- `apps/journal/app/lib/sync/types.ts` — extend `SyncProvider` interface.
|
||||
- `apps/journal/app/routes/` — new `/api/sync/push/wahoo/:routeId` action; "Send to Wahoo" button on the route detail page.
|
||||
- `packages/db/src/schema/journal.ts` — new `sync_pushes` table + `granted_scopes` column on `sync_connections`, plus the generated Drizzle migration in `packages/db/migrations/`.
|
||||
- **New package** `packages/fit/` — GPX→FIT Course encoder, co-located tests with FIT-decoder round-trip fixtures.
|
||||
- **Dependencies**: One new dep — `@garmin/fitsdk` (Garmin's official ESM-native FIT SDK; see design.md §1 for evaluation). `fit-file-parser` is already a dep on the import path and is reused as the round-trip oracle in tests.
|
||||
- **External API**: Net-new outbound calls to `api.wahooligan.com/v1/routes`. No webhook changes.
|
||||
- **Privacy manifest**: Update `docs/privacy.md` (or wherever the manifest lives) to declare that route geometry + name + description are sent to Wahoo when the user opts in by clicking "Send to Wahoo".
|
||||
- **Out of scope for this change**: bulk push ("send all my routes"), automatic push on route save, push to non-Wahoo providers, and pushing route updates after the initial send (PUT /v1/routes/:id can come later — first send wins).
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Connect Wahoo account
|
||||
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`, `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
|
||||
- **THEN** the stored tokens are deleted from `sync_connections`
|
||||
|
||||
#### Scenario: Token refresh
|
||||
- **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
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
## ADDED 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:<route_id>:v<route_version>`
|
||||
- **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: <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 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 <date>" 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: <short error>" with a "Send to Wahoo" button still active
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
## 1. FIT Course encoder package
|
||||
|
||||
- [x] 1.1 Scaffold `packages/fit/` (package.json, tsconfig, vitest config) following the pattern of `packages/gpx/`
|
||||
- [x] 1.2 Add the package to `pnpm-workspace.yaml` and reference it from `apps/journal/package.json`
|
||||
- [x] 1.3 Add `@garmin/fitsdk` dependency to `packages/fit/package.json` and a small ambient `src/fitsdk.d.ts` declaring the `Encoder`, `Stream`, and `Profile.MesgNum` shapes we use (the SDK ships no types)
|
||||
- [x] 1.4 Wrap the SDK Encoder to emit the Course message stream Wahoo needs: `file_id` (type=course), `file_creator`, `course` (name, sport, capabilities), `lap` (start/end position, distance, elapsed time = 0), `event` (timer start at first record, timer stop at last), and `record` (position_lat, position_long, altitude, distance)
|
||||
- [x] 1.5 Implement `gpxToFitCourse({ gpx, name, description?, sport? })`: parse GPX via `@trails-cool/gpx`, compute distance and ascent, drive the SDK encoder, return `Uint8Array`
|
||||
- [x] 1.6 Add fixtures in `packages/fit/__fixtures__/` covering: short flat route, alpine route with elevation, multi-day route, route with single track point (should still encode), zero-track-point route (should throw)
|
||||
- [x] 1.7 Write round-trip tests: encode each fixture, decode with `fit-file-parser`, assert track-point count and lat/lon/elevation parity within tolerance (1e-5 deg, 0.5 m)
|
||||
- [x] 1.8 Add the package to `pnpm test` and `pnpm typecheck` runs (should be automatic via Turborepo)
|
||||
|
||||
## 2. Database schema for push tracking
|
||||
|
||||
- [x] 2.1 Add `sync_pushes` table to `packages/db/src/schema/journal.ts` with columns from design.md §3 and a unique index on `(user_id, route_id, route_version, provider)`
|
||||
- [x] 2.2 Add a `granted_scopes text[]` column to `sync_connections` in the same schema file, defaulting to an empty array. (Backfill happens at `exchangeCode` time in slice 3 — pre-existing connections will be recognized as scope-mismatched on first push and trigger re-auth, which is the intended UX.)
|
||||
- [x] 2.3 ~~Generate Drizzle migration files~~ — N/A: this repo runs `drizzle-kit push --force` schema-first, no checked-in `migrations/` directory. The schema edit is the migration source.
|
||||
- [ ] 2.4 Apply locally via `pnpm db:push` against a running dev Postgres and verify with `pnpm db:studio` — deferred: requires `pnpm dev:services` (Docker) which isn't running in this workspace. CI applies on deploy.
|
||||
|
||||
## 3. Wahoo provider scope upgrade
|
||||
|
||||
- [x] 3.1 Add `routes_write` to the Wahoo scopes array in `apps/journal/app/lib/sync/providers/wahoo.ts:14`
|
||||
- [x] 3.2 Persist `granted_scopes` on the `sync_connections` row at `exchangeCode` time. Wahoo's token endpoint does not return a `scope` field and grants scopes all-or-nothing, so record the requested scope set as granted. The scope-mismatch detection in §5.2 is therefore a comparison against our own constant, not against provider-reported state — that's intentional and only needs to flag pre-`routes_write` connections.
|
||||
- [x] 3.3 Implement `pushRoute(connection, { gpx, name, description, externalId })` on the Wahoo provider: base64-encode the FIT, build the form-encoded body, POST `/v1/routes`, return `{ remoteId }` on success or throw a typed error on failure (token expired, scope missing, validation error, rate limit, generic)
|
||||
- [x] 3.4 Wire token-refresh-on-401 through the new push call. Implemented at the boundary instead of inside `pushRoute`: the provider throws `PushError({ code: "token_expired" })` on 401 and the slice-4 action route catches it, calls `provider.refreshToken`, persists, and retries once. (No `withFreshToken` helper exists in this repo today; the existing webhook handler does the same inline.)
|
||||
|
||||
## 4. Sync provider interface extension
|
||||
|
||||
- [x] 4.1 Add an optional `pushRoute?: (connection, payload) => Promise<{ remoteId: string }>` method to the `SyncProvider` interface in `apps/journal/app/lib/sync/types.ts`
|
||||
- [x] 4.2 Define the `PushRoutePayload` and `PushRouteResult` types alongside it
|
||||
- [x] 4.3 Add a `providerSupportsPush(provider)` helper for use in loaders/UI
|
||||
|
||||
## 5. Push action route and idempotency layer
|
||||
|
||||
- [x] 5.1 Add an action route at `/api/sync/push/wahoo/:routeId` that resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push
|
||||
- [x] 5.2 Before calling Wahoo, check `sync_connections.granted_scopes` for `routes_write`; if missing, redirect to `getAuthUrl` with state `{ return_to, push_after }`
|
||||
- [x] 5.3 Look up an existing `sync_pushes` row for `(user_id, route_id, route_version, 'wahoo')` — if `pushed_at` is set, return the existing `remote_id` without calling Wahoo
|
||||
- [x] 5.4 On a fresh push, build the payload (`external_id = "route:<routeId>:v<version>"`, current timestamp, route name/description, start lat/lng, computed distance and ascent), call `provider.pushRoute`, and write the result to `sync_pushes` (insert on success, update existing row on retry of a failed attempt)
|
||||
- [x] 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry
|
||||
|
||||
## 6. OAuth callback resumes pending push
|
||||
|
||||
- [x] 6.1 Extend the OAuth callback handler at `/api/sync/callback/wahoo` to parse `state.push_after` and `state.return_to`
|
||||
- [x] 6.2 If `push_after`, after persisting the new tokens and scopes, run the push pipeline server-side and redirect to `return_to` with a success or failure flash message
|
||||
- [x] 6.3 If the user denied the new scope (callback arrives with `?error=access_denied`), redirect to `return_to` with the "needs route permission" notice
|
||||
|
||||
## 7. Route detail page UI
|
||||
|
||||
- [x] 7.1 In the route detail loader, include the latest `sync_pushes` row for the current version and the user's Wahoo connection presence + scopes
|
||||
- [x] 7.2 Render the "Send to Wahoo" button next to the existing Export GPX action when conditions in spec §1 are met
|
||||
- [x] 7.3 Render the post-push status: "Sent to Wahoo on <date>" replacing the button when a successful push exists
|
||||
- [x] 7.4 Render the failed-push state: "Last attempt failed: <short error>" with the button still active
|
||||
- [x] 7.5 Add the button copy "This sends your route to Wahoo" as a tooltip or helper text near the button (privacy disclosure at the action point)
|
||||
- [x] 7.6 Add i18n strings for all new UI copy (English + German) per the i18n convention in CLAUDE.md
|
||||
|
||||
## 8. Privacy manifest
|
||||
|
||||
- [x] 8.1 Privacy disclosure added to `apps/journal/app/routes/legal.privacy.tsx` (the live in-app /legal/privacy page — this repo doesn't have a separate `docs/privacy.md` manifest). The new bullet describes that route geometry, name, and description are transmitted to Wahoo on opt-in.
|
||||
- [x] 8.2 Cross-linked: proposal.md already references the privacy disclosure, and `legal.privacy.tsx` is the canonical disclosure surface.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
- [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7
|
||||
- [x] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit
|
||||
- [x] 9.3 Unit tests for the scope-mismatch redirect flow
|
||||
- [ ] 9.4 E2E test for the success path — deferred. Needs new infra: a Wahoo HTTP mock alongside the dev server (intercepting `api.wahooligan.com/v1/routes` server-side, not via Playwright `page.route`). The unit-test layer in slice 4 already covers the pipeline against a mocked provider; promoting these to full-stack E2E should land with a shared `wahoo-mock.ts` fixture once we have a second push provider or a real-API smoke harness.
|
||||
- [ ] 9.5 E2E test for the re-auth flow — deferred for the same reason as 9.4.
|
||||
|
||||
## 10. Manual verification before merge
|
||||
|
||||
- [ ] 10.1 Smoke test against Wahoo's real API in a dev sandbox: push a real route, confirm it appears in the Wahoo App and on a physical ELEMNT/BOLT/ROAM
|
||||
- [ ] 10.2 Verify `provider_updated_at` and `external_id` semantics: push v1, edit and push v2, confirm both appear as separate routes on Wahoo
|
||||
- [ ] 10.3 Verify error path: revoke `routes_write` from the dev account in Wahoo's UI, click Send to Wahoo, confirm the re-auth flow runs and completes the push
|
||||
- [ ] 10.4 Verify the privacy manifest entry is reachable from the in-app privacy disclosure
|
||||
|
||||
## 11. Release
|
||||
|
||||
- [ ] 11.1 Run `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e`
|
||||
- [ ] 11.2 Open PR with the change summary linking to `openspec/changes/wahoo-route-push/proposal.md`
|
||||
- [ ] 11.3 After merge, monitor `sync_pushes.error` for the first week; if a recurring failure mode shows up, file a follow-up
|
||||
- [ ] 11.4 Run `/opsx:archive wahoo-route-push` once the change is live and confirmed
|
||||
Loading…
Add table
Add a link
Reference in a new issue