diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ad0a6d7..4c40d20 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -13,7 +13,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `ics.ts` | **COMPLETE** | 23 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling | | `encryption.ts` | **COMPLETE** | 14 tests covering AES-256-GCM encrypt/decrypt round-trip, error handling, key validation | | `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests | -| `garmin.ts` | **COMPLETE** | 14 tests covering fetchGarminData, isTokenExpired, daysUntilExpiry, error handling, token validation | +| `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation | | `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` | | `auth-middleware.ts` | **COMPLETE** | 6 tests covering `withAuth()` wrapper for API route protection | | `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login | @@ -81,7 +81,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/email.test.ts` | **EXISTS** - 14 tests (email content, subject lines, formatting) | | `src/lib/ics.test.ts` | **EXISTS** - 23 tests (ICS format validation, 90-day event generation, timezone handling) | | `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) | -| `src/lib/garmin.test.ts` | **EXISTS** - 14 tests (API calls, token expiry, error handling) | +| `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -248,14 +248,17 @@ Minimum viable product - app can be used for daily decisions. Full feature set for production use. -### P2.1: Garmin Data Fetching Functions -- [ ] Add specific fetchers for HRV, Body Battery, Intensity Minutes +### P2.1: Garmin Data Fetching Functions ✅ COMPLETE +- [x] Add specific fetchers for HRV, Body Battery, Intensity Minutes - **Files:** - - `src/lib/garmin.ts` - Add `fetchHrvStatus()`, `fetchBodyBattery()`, `fetchIntensityMinutes()` + - `src/lib/garmin.ts` - Added `fetchHrvStatus()`, `fetchBodyBattery()`, `fetchIntensityMinutes()` - **Tests:** - - `src/lib/garmin.test.ts` - Test API calls, response parsing, error handling + - `src/lib/garmin.test.ts` - 33 tests covering API calls, response parsing, error handling (increased from 14 tests) +- **Functions Implemented:** + - `fetchHrvStatus()` - Fetches HRV status (balanced/unbalanced) from Garmin + - `fetchBodyBattery()` - Fetches current and yesterday's low body battery values + - `fetchIntensityMinutes()` - Fetches weekly moderate + vigorous intensity minutes - **Why:** Real biometric data is required for accurate decisions -- **Note:** Currently only has generic fetchGarminData, isTokenExpired, daysUntilExpiry ### P2.2: POST/DELETE /api/garmin/tokens Implementation - [ ] Store encrypted Garmin OAuth tokens @@ -442,12 +445,13 @@ Testing, error handling, and refinements. ### P3.6: Garmin Tests ✅ COMPLETE - [x] Unit tests for Garmin API interactions - **Files:** - - `src/lib/garmin.test.ts` - 14 tests covering API calls, error handling, token expiry + - `src/lib/garmin.test.ts` - 33 tests covering API calls, error handling, token expiry (expanded in P2.1) - **Test Cases Covered:** - - fetchGarminData HTTP calls and response parsing + - fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes HTTP calls and response parsing - isTokenExpired logic with various expiry dates - daysUntilExpiry calculations - Error handling for invalid tokens and network failures + - Response parsing for biometric data structures - **Why:** External API integration robustness is now fully tested ### P3.7: Error Handling Improvements @@ -532,7 +536,7 @@ P2.14 Mini calendar - [x] **email.ts** - Complete with 14 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, email formatting) (P3.3) - [x] **ics.ts** - Complete with 23 tests (`generateIcsFeed`, ICS format validation, 90-day event generation) (P3.4) - [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5) -- [x] **garmin.ts** - Complete with 14 tests (`fetchGarminData`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P3.6) +- [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6) ### Components - [x] **DecisionCard** - Displays decision status, icon, and reason @@ -565,7 +569,7 @@ P2.14 Mini calendar - [x] ~~`src/lib/auth-middleware.ts` does not exist~~ - CREATED in P0.2 - [x] ~~`src/middleware.ts` does not exist~~ - CREATED in P0.2 -- [ ] `garmin.ts` is only ~30% complete - missing specific biometric fetchers +- [x] ~~`garmin.ts` is only ~30% complete - missing specific biometric fetchers~~ - FIXED in P2.1 (added fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes) - [x] ~~`pocketbase.ts` missing all auth helper functions~~ - FIXED in P0.1 - [x] ~~`src/app/api/today/route.ts` type error with null body battery values~~ - FIXED (added null coalescing) diff --git a/src/lib/garmin.test.ts b/src/lib/garmin.test.ts index 788723b..49b97b5 100644 --- a/src/lib/garmin.test.ts +++ b/src/lib/garmin.test.ts @@ -4,7 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GarminTokens } from "@/types"; -import { daysUntilExpiry, fetchGarminData, isTokenExpired } from "./garmin"; +import { + daysUntilExpiry, + fetchBodyBattery, + fetchGarminData, + fetchHrvStatus, + fetchIntensityMinutes, + isTokenExpired, +} from "./garmin"; describe("isTokenExpired", () => { it("returns false when token expires in the future", () => { @@ -192,3 +199,319 @@ describe("fetchGarminData", () => { ).rejects.toThrow("Network error"); }); }); + +describe("fetchHrvStatus", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("returns Balanced when API returns BALANCED status", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" }, + }), + }); + + const result = await fetchHrvStatus("2024-01-15", "test-token"); + + expect(result).toBe("Balanced"); + expect(global.fetch).toHaveBeenCalledWith( + "https://connect.garmin.com/modern/proxy/hrv-service/hrv/2024-01-15", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + it("returns Unbalanced when API returns UNBALANCED status", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + hrvSummary: { lastNightAvg: 25, weeklyAvg: 42, status: "UNBALANCED" }, + }), + }); + + const result = await fetchHrvStatus("2024-01-15", "test-token"); + + expect(result).toBe("Unbalanced"); + }); + + it("returns Unknown when API returns no data", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + const result = await fetchHrvStatus("2024-01-15", "test-token"); + + expect(result).toBe("Unknown"); + }); + + it("returns Unknown when API returns null hrvSummary", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ hrvSummary: null }), + }); + + const result = await fetchHrvStatus("2024-01-15", "test-token"); + + expect(result).toBe("Unknown"); + }); + + it("returns Unknown when API request fails", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }); + + const result = await fetchHrvStatus("2024-01-15", "test-token"); + + expect(result).toBe("Unknown"); + }); + + it("returns Unknown on network error", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + const result = await fetchHrvStatus("2024-01-15", "test-token"); + + expect(result).toBe("Unknown"); + }); +}); + +describe("fetchBodyBattery", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("returns current and yesterday low values on success", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + bodyBatteryValuesArray: [ + { date: "2024-01-15", charged: 85, drained: 60 }, + ], + bodyBatteryStatList: [{ date: "2024-01-14", min: 25, max: 95 }], + }), + }); + + const result = await fetchBodyBattery("2024-01-15", "test-token"); + + expect(result).toEqual({ + current: 85, + yesterdayLow: 25, + }); + expect(global.fetch).toHaveBeenCalledWith( + "https://connect.garmin.com/modern/proxy/usersummary-service/stats/bodyBattery/dates/2024-01-15", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + it("returns null values when data is missing", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + bodyBatteryValuesArray: [], + bodyBatteryStatList: [], + }), + }); + + const result = await fetchBodyBattery("2024-01-15", "test-token"); + + expect(result).toEqual({ + current: null, + yesterdayLow: null, + }); + }); + + it("returns null values when API returns empty object", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + const result = await fetchBodyBattery("2024-01-15", "test-token"); + + expect(result).toEqual({ + current: null, + yesterdayLow: null, + }); + }); + + it("returns null values when API request fails", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + const result = await fetchBodyBattery("2024-01-15", "test-token"); + + expect(result).toEqual({ + current: null, + yesterdayLow: null, + }); + }); + + it("returns null values on network error", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + const result = await fetchBodyBattery("2024-01-15", "test-token"); + + expect(result).toEqual({ + current: null, + yesterdayLow: null, + }); + }); + + it("handles partial data - only current available", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + bodyBatteryValuesArray: [{ date: "2024-01-15", charged: 70 }], + bodyBatteryStatList: [], + }), + }); + + const result = await fetchBodyBattery("2024-01-15", "test-token"); + + expect(result).toEqual({ + current: 70, + yesterdayLow: null, + }); + }); +}); + +describe("fetchIntensityMinutes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("returns 7-day intensity minutes total on success", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + weeklyTotal: { + moderateIntensityMinutes: 45, + vigorousIntensityMinutes: 30, + }, + }), + }); + + const result = await fetchIntensityMinutes("test-token"); + + expect(result).toBe(75); + expect(global.fetch).toHaveBeenCalledWith( + "https://connect.garmin.com/modern/proxy/fitnessstats-service/activity", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + it("returns 0 when no intensity data available", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + const result = await fetchIntensityMinutes("test-token"); + + expect(result).toBe(0); + }); + + it("returns 0 when weeklyTotal is null", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ weeklyTotal: null }), + }); + + const result = await fetchIntensityMinutes("test-token"); + + expect(result).toBe(0); + }); + + it("handles only moderate intensity minutes", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + weeklyTotal: { + moderateIntensityMinutes: 60, + vigorousIntensityMinutes: 0, + }, + }), + }); + + const result = await fetchIntensityMinutes("test-token"); + + expect(result).toBe(60); + }); + + it("handles only vigorous intensity minutes", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + weeklyTotal: { + moderateIntensityMinutes: 0, + vigorousIntensityMinutes: 45, + }, + }), + }); + + const result = await fetchIntensityMinutes("test-token"); + + expect(result).toBe(45); + }); + + it("returns 0 when API request fails", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + }); + + const result = await fetchIntensityMinutes("test-token"); + + expect(result).toBe(0); + }); + + it("returns 0 on network error", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + const result = await fetchIntensityMinutes("test-token"); + + expect(result).toBe(0); + }); +}); diff --git a/src/lib/garmin.ts b/src/lib/garmin.ts index 6e525b3..6e43edc 100644 --- a/src/lib/garmin.ts +++ b/src/lib/garmin.ts @@ -1,6 +1,6 @@ // ABOUTME: Garmin Connect API client using stored OAuth tokens. // ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin. -import type { GarminTokens } from "@/types"; +import type { GarminTokens, HrvStatus } from "@/types"; const GARMIN_BASE_URL = "https://connect.garmin.com/modern/proxy"; @@ -8,6 +8,11 @@ interface GarminApiOptions { oauth2Token: string; } +export interface BodyBatteryData { + current: number | null; + yesterdayLow: number | null; +} + export async function fetchGarminData( endpoint: string, options: GarminApiOptions, @@ -37,3 +42,101 @@ export function daysUntilExpiry(tokens: GarminTokens): number { const diffMs = expiresAt.getTime() - now.getTime(); return Math.floor(diffMs / (1000 * 60 * 60 * 24)); } + +export async function fetchHrvStatus( + date: string, + oauth2Token: string, +): Promise { + try { + const response = await fetch(`${GARMIN_BASE_URL}/hrv-service/hrv/${date}`, { + headers: { + Authorization: `Bearer ${oauth2Token}`, + NK: "NT", + }, + }); + + if (!response.ok) { + return "Unknown"; + } + + const data = await response.json(); + const status = data?.hrvSummary?.status; + + if (status === "BALANCED") { + return "Balanced"; + } + if (status === "UNBALANCED") { + return "Unbalanced"; + } + return "Unknown"; + } catch { + return "Unknown"; + } +} + +export async function fetchBodyBattery( + date: string, + oauth2Token: string, +): Promise { + try { + const response = await fetch( + `${GARMIN_BASE_URL}/usersummary-service/stats/bodyBattery/dates/${date}`, + { + headers: { + Authorization: `Bearer ${oauth2Token}`, + NK: "NT", + }, + }, + ); + + if (!response.ok) { + return { current: null, yesterdayLow: null }; + } + + const data = await response.json(); + + const currentData = data?.bodyBatteryValuesArray?.[0]; + const current = currentData?.charged ?? null; + + const yesterdayStats = data?.bodyBatteryStatList?.[0]; + const yesterdayLow = yesterdayStats?.min ?? null; + + return { current, yesterdayLow }; + } catch { + return { current: null, yesterdayLow: null }; + } +} + +export async function fetchIntensityMinutes( + oauth2Token: string, +): Promise { + try { + const response = await fetch( + `${GARMIN_BASE_URL}/fitnessstats-service/activity`, + { + headers: { + Authorization: `Bearer ${oauth2Token}`, + NK: "NT", + }, + }, + ); + + if (!response.ok) { + return 0; + } + + const data = await response.json(); + const weeklyTotal = data?.weeklyTotal; + + if (!weeklyTotal) { + return 0; + } + + const moderate = weeklyTotal.moderateIntensityMinutes ?? 0; + const vigorous = weeklyTotal.vigorousIntensityMinutes ?? 0; + + return moderate + vigorous; + } catch { + return 0; + } +}