Address 21 previously undefined behaviors across specs: - Authentication: Replace email/password with OIDC (Pocket-ID) - Cycle tracking: Add fixed-luteal phase scaling formula with examples - Calendar: Document period logging behavior (preserve predictions) - Garmin: Clarify connection is required (no phase-only mode) - Dashboard: Add UI states, dark mode, onboarding, accessibility - Notifications: Document timezone batching approach - New specs: observability.md (health, metrics, logging) - New specs: testing.md (unit + integration strategy) - Main spec: Add backup/recovery, known limitations, API updates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
111 lines
3.1 KiB
Markdown
111 lines
3.1 KiB
Markdown
# Garmin Integration Specification
|
|
|
|
> **Required**: Garmin connection is mandatory. PhaseFlow cannot function without biometric data from Garmin. The app's core value proposition is combining cycle phases with real physiological data—phase-only mode is not supported.
|
|
|
|
## Job to Be Done
|
|
|
|
When I connect my Garmin account, I want the app to automatically sync my biometrics daily, so that training decisions are based on real physiological data.
|
|
|
|
## OAuth Flow
|
|
|
|
### Initial Token Bootstrap (Manual)
|
|
|
|
Garmin's Connect API uses OAuth 1.0a → OAuth 2.0 exchange.
|
|
|
|
**Process:**
|
|
1. User runs `scripts/garmin_auth.py` locally
|
|
2. Script obtains OAuth 1.0a request token
|
|
3. User authorizes in browser
|
|
4. Script exchanges for OAuth 2.0 access token
|
|
5. User pastes tokens into Settings > Garmin page
|
|
|
|
**Tokens Stored:**
|
|
- `oauth1` - OAuth 1.0a credentials (JSON, encrypted)
|
|
- `oauth2` - OAuth 2.0 access token (JSON, encrypted)
|
|
- `expires_at` - Token expiration timestamp
|
|
|
|
### Token Storage
|
|
|
|
Tokens encrypted with AES-256-GCM using `ENCRYPTION_KEY` env var.
|
|
|
|
**Functions (`src/lib/encryption.ts`):**
|
|
- `encrypt(plaintext: string): string` - Returns base64 ciphertext
|
|
- `decrypt(ciphertext: string): string` - Returns plaintext
|
|
|
|
## API Endpoints
|
|
|
|
### GET `/api/garmin/status`
|
|
|
|
Returns current Garmin connection status.
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"connected": true,
|
|
"daysUntilExpiry": 85,
|
|
"lastSync": "2024-01-10T07:00:00Z"
|
|
}
|
|
```
|
|
|
|
### POST `/api/garmin/tokens`
|
|
|
|
Updates stored Garmin tokens.
|
|
|
|
**Request Body:**
|
|
```json
|
|
{
|
|
"oauth1": { ... },
|
|
"oauth2": { ... },
|
|
"expires_at": "2024-04-10T00:00:00Z"
|
|
}
|
|
```
|
|
|
|
## Garmin Data Fetching
|
|
|
|
### Daily Sync (`/api/cron/garmin-sync`)
|
|
|
|
Runs via cron job at 6:00 AM user's timezone.
|
|
|
|
**Endpoints Called:**
|
|
1. `/usersummary-service/stats/bodyBattery/dates/{date}` - Body Battery
|
|
2. `/hrv-service/hrv/{date}` - HRV Status
|
|
3. `/fitnessstats-service/activity` - Intensity Minutes
|
|
|
|
**Data Extracted:**
|
|
- `bodyBatteryCurrent` - Latest BB value
|
|
- `bodyBatteryYesterdayLow` - Yesterday's minimum BB
|
|
- `hrvStatus` - "Balanced" or "Unbalanced"
|
|
- `weekIntensityMinutes` - 7-day rolling sum
|
|
|
|
### Garmin Client (`src/lib/garmin.ts`)
|
|
|
|
**Functions:**
|
|
- `fetchGarminData(endpoint, { oauth2Token })` - Generic API caller
|
|
- `isTokenExpired(tokens)` - Check if refresh needed
|
|
- `daysUntilExpiry(tokens)` - Days until token expires
|
|
|
|
## Token Expiration Handling
|
|
|
|
Garmin OAuth 2.0 tokens expire after ~90 days.
|
|
|
|
**Warning Triggers:**
|
|
- 14 days before: Yellow warning in Settings
|
|
- 7 days before: Email notification
|
|
- 0 days: Red alert, manual refresh required
|
|
|
|
## Success Criteria
|
|
|
|
1. Tokens stored encrypted at rest
|
|
2. Daily sync completes before 7 AM notification
|
|
3. Token expiration warnings sent 14 and 7 days before
|
|
4. Failed syncs logged with actionable error messages
|
|
|
|
## Acceptance Tests
|
|
|
|
- [ ] Encrypt/decrypt round-trips correctly
|
|
- [ ] `/api/garmin/status` returns accurate days until expiry
|
|
- [ ] `/api/garmin/tokens` validates token structure
|
|
- [ ] Cron sync fetches all three Garmin endpoints
|
|
- [ ] Expired token triggers appropriate error handling
|
|
- [ ] Token expiration warning at 14-day threshold
|