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:
2026-01-10 19:38:22 +00:00
parent 0d88066d00
commit c1679789b5
3 changed files with 443 additions and 13 deletions

View File

@@ -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);
});
});

View File

@@ -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<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;
}
}