Implement Garmin biometric fetching functions (P2.1)
Add specific fetchers for HRV, Body Battery, and Intensity Minutes to enable real biometric data collection from Garmin Connect API. Functions added: - fetchHrvStatus(): Returns "Balanced", "Unbalanced", or "Unknown" - fetchBodyBattery(): Returns current BB and yesterday's low value - fetchIntensityMinutes(): Returns 7-day rolling sum of activity All functions gracefully handle API failures with safe defaults. Test count expanded from 14 to 33 covering all scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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()` |
|
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
|
||||||
| `auth-middleware.ts` | **COMPLETE** | 6 tests covering `withAuth()` wrapper for API route protection |
|
| `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 |
|
| `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/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/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/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** |
|
| E2E tests | **NONE** |
|
||||||
|
|
||||||
### Critical Business Rules (from Spec)
|
### 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.
|
Full feature set for production use.
|
||||||
|
|
||||||
### P2.1: Garmin Data Fetching Functions
|
### P2.1: Garmin Data Fetching Functions ✅ COMPLETE
|
||||||
- [ ] Add specific fetchers for HRV, Body Battery, Intensity Minutes
|
- [x] Add specific fetchers for HRV, Body Battery, Intensity Minutes
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/lib/garmin.ts` - Add `fetchHrvStatus()`, `fetchBodyBattery()`, `fetchIntensityMinutes()`
|
- `src/lib/garmin.ts` - Added `fetchHrvStatus()`, `fetchBodyBattery()`, `fetchIntensityMinutes()`
|
||||||
- **Tests:**
|
- **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
|
- **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
|
### P2.2: POST/DELETE /api/garmin/tokens Implementation
|
||||||
- [ ] Store encrypted Garmin OAuth tokens
|
- [ ] Store encrypted Garmin OAuth tokens
|
||||||
@@ -442,12 +445,13 @@ Testing, error handling, and refinements.
|
|||||||
### P3.6: Garmin Tests ✅ COMPLETE
|
### P3.6: Garmin Tests ✅ COMPLETE
|
||||||
- [x] Unit tests for Garmin API interactions
|
- [x] Unit tests for Garmin API interactions
|
||||||
- **Files:**
|
- **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:**
|
- **Test Cases Covered:**
|
||||||
- fetchGarminData HTTP calls and response parsing
|
- fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes HTTP calls and response parsing
|
||||||
- isTokenExpired logic with various expiry dates
|
- isTokenExpired logic with various expiry dates
|
||||||
- daysUntilExpiry calculations
|
- daysUntilExpiry calculations
|
||||||
- Error handling for invalid tokens and network failures
|
- Error handling for invalid tokens and network failures
|
||||||
|
- Response parsing for biometric data structures
|
||||||
- **Why:** External API integration robustness is now fully tested
|
- **Why:** External API integration robustness is now fully tested
|
||||||
|
|
||||||
### P3.7: Error Handling Improvements
|
### 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] **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] **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] **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
|
### Components
|
||||||
- [x] **DecisionCard** - Displays decision status, icon, and reason
|
- [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/lib/auth-middleware.ts` does not exist~~ - CREATED in P0.2
|
||||||
- [x] ~~`src/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] ~~`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)
|
- [x] ~~`src/app/api/today/route.ts` type error with null body battery values~~ - FIXED (added null coalescing)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { GarminTokens } from "@/types";
|
import type { GarminTokens } from "@/types";
|
||||||
|
|
||||||
import { daysUntilExpiry, fetchGarminData, isTokenExpired } from "./garmin";
|
import {
|
||||||
|
daysUntilExpiry,
|
||||||
|
fetchBodyBattery,
|
||||||
|
fetchGarminData,
|
||||||
|
fetchHrvStatus,
|
||||||
|
fetchIntensityMinutes,
|
||||||
|
isTokenExpired,
|
||||||
|
} from "./garmin";
|
||||||
|
|
||||||
describe("isTokenExpired", () => {
|
describe("isTokenExpired", () => {
|
||||||
it("returns false when token expires in the future", () => {
|
it("returns false when token expires in the future", () => {
|
||||||
@@ -192,3 +199,319 @@ describe("fetchGarminData", () => {
|
|||||||
).rejects.toThrow("Network error");
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ABOUTME: Garmin Connect API client using stored OAuth tokens.
|
// ABOUTME: Garmin Connect API client using stored OAuth tokens.
|
||||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin.
|
// 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";
|
const GARMIN_BASE_URL = "https://connect.garmin.com/modern/proxy";
|
||||||
|
|
||||||
@@ -8,6 +8,11 @@ interface GarminApiOptions {
|
|||||||
oauth2Token: string;
|
oauth2Token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BodyBatteryData {
|
||||||
|
current: number | null;
|
||||||
|
yesterdayLow: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchGarminData(
|
export async function fetchGarminData(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: GarminApiOptions,
|
options: GarminApiOptions,
|
||||||
@@ -37,3 +42,101 @@ export function daysUntilExpiry(tokens: GarminTokens): number {
|
|||||||
const diffMs = expiresAt.getTime() - now.getTime();
|
const diffMs = expiresAt.getTime() - now.getTime();
|
||||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchHrvStatus(
|
||||||
|
date: string,
|
||||||
|
oauth2Token: string,
|
||||||
|
): Promise<HrvStatus> {
|
||||||
|
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<BodyBatteryData> {
|
||||||
|
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<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user