- wahoo-import: clarify fitToGpx lives at connected-services/fit.ts (shared across providers) not inside the wahoo directory - notifications: correct follow payload field names to followerUsername, followerDisplayName, targetUsername, targetDisplayName (matches code) - shared-packages: add @trails-cool/fit package entry + CAPABILITIES.md index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.4 KiB
Purpose
Provider-agnostic activity sync framework with Wahoo as the first provider, supporting OAuth connection, webhook-based automatic sync, manual import, and FIT-to-GPX conversion.
Requirements
Requirement: Provider-agnostic sync framework
The system SHALL provide capability-shaped seams for external activity sync providers. Each provider declares a manifest at providers/<name>/manifest.ts listing its credential_kind (oauth | web-login | device) and the capability adapters it implements (Importer, RoutePusher, WebhookReceiver). There SHALL NOT be a unified SyncProvider interface containing every capability with optional methods.
Scenario: Add new provider
- WHEN a developer wants to add a new sync provider (e.g., Garmin)
- THEN they create
providers/garmin/containing amanifest.tsdeclaringcredential_kindand the implemented capabilities - AND they implement only the capability interfaces that apply (e.g.
Importerfor an import-only provider) - AND they register the manifest in
providers/registry.ts - AND OAuth flows, webhook routing, and settings UI work without further changes
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
connected_services(in thecredentialsJSONB blob, withcredential_kind = 'oauth') - AND the granted scopes are recorded in
connected_services.granted_scopesso 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 credentials are deleted from
connected_services
Scenario: Token refresh
- WHEN a Wahoo API call fails with an expired token
- THEN the OAuth
CredentialAdapteris invoked viaConnectedServiceManager.withFreshCredentialsto obtain a new access token automatically - AND the new tokens are written back to the
credentialsJSONB blob
Scenario: Existing connection without routes_write
- WHEN a user connected before the
routes_writescope was added attempts an action that requires it (such as pushing a route) - THEN the system detects the missing scope from
connected_services.granted_scopesand 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.
Scenario: Webhook receives new workout
- WHEN Wahoo sends a
workout_summarywebhook to/api/sync/webhook/wahoo - THEN the system identifies the user via
provider_user_id - AND downloads the FIT file from Wahoo's CDN (without auth headers, as CDN URLs are pre-signed)
- AND converts it to GPX
- AND creates a journal activity with the GPX, stats, and PostGIS geometry
- AND records the import in
sync_importsto prevent duplicates
Scenario: Webhook for workout without file
- WHEN a webhook arrives for a workout with no FIT file URL
- THEN the activity is created without GPX or geometry
Scenario: Duplicate webhook
- WHEN a webhook arrives for a workout already imported
- THEN the import is skipped silently (idempotent)
Scenario: Unknown user webhook
- WHEN a webhook arrives with a
provider_user_idnot matching any connection - THEN the request is ignored with a 200 response (don't reveal user existence)
Requirement: Manual import
Users SHALL be able to browse and selectively import older Wahoo workouts.
Scenario: View workout list
- WHEN a user visits the Wahoo import page
- THEN their Wahoo workouts are listed with date, type, duration, and distance
- AND already-imported workouts are marked as "Imported"
- AND third-party workouts (fitness_app_id >= 1000) are filtered out, as Wahoo does not share their data via the API
- AND workouts without a FIT file show a "No GPS" badge with a tooltip explaining the provider has no route data
Scenario: Import single workout
- WHEN a user clicks "Import" on a Wahoo workout
- THEN the import runs in the background using a fetcher (no page refresh)
- AND the button shows "Importing..." during the import
- AND changes to "Imported" when complete
Scenario: Import all workouts
- WHEN a user clicks "Import all"
- THEN all unimported workouts on the current page are imported sequentially
- AND a progress indicator shows "Importing X of Y..."
Requirement: Activity import metadata
Imported activities SHALL show their origin in the UI.
Scenario: View imported activity
- WHEN a user views an activity that was imported from Wahoo
- THEN an "Imported from wahoo" badge is displayed on the detail page
Scenario: Delete and reimport
- WHEN a user deletes an imported activity
- THEN the sync_imports record is also deleted
- AND the workout appears as importable again on the import page
Requirement: FIT to GPX conversion
The system SHALL convert FIT binary files to GPX format. The conversion logic lives at apps/journal/app/lib/connected-services/fit.ts — a provider-agnostic location shared across any future provider that produces FIT files (Garmin, Coros, etc.). Wahoo's importer and webhook both import from this shared module; they do not contain their own copy.
Scenario: Convert FIT with GPS data
- WHEN a FIT file contains GPS track records
- THEN track points with lat, lon, elevation, and ISO 8601 timestamps are extracted
- AND coordinates are used as-is from the FIT parser (which already converts semicircles to degrees)
- AND a valid GPX string is produced using
generateGpx
Scenario: FIT without GPS data
- WHEN a FIT file has no GPS records (e.g., indoor trainer workout)
- THEN the activity is created without GPX or geometry (stats only)
Scenario: Workout without FIT file
- WHEN a workout has no file URL (e.g., aborted recording, third-party app data)
- THEN the activity is created without GPX or geometry