Setup Ralph.
This commit is contained in:
108
specs/garmin-integration.md
Normal file
108
specs/garmin-integration.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Garmin Integration Specification
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user