Files
phaseflow/specs/garmin-integration.md
Petru Paler 6a8d55c0b9 Document spec gaps: auth, phase scaling, observability, testing
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>
2026-01-11 07:49:56 +00:00

3.1 KiB

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:

{
  "connected": true,
  "daysUntilExpiry": 85,
  "lastSync": "2024-01-10T07:00:00Z"
}

POST /api/garmin/tokens

Updates stored Garmin tokens.

Request Body:

{
  "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