mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(polar): use AccessLink collection endpoints
This commit is contained in:
1 parent
74a386fcfd
commit
b9425de40e
4 files changed
+159
-75
No files matched your search
@@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
POLAR_OAUTH_SCOPE,
|
||||
exchangeCode,
|
||||
fetchActivities,
|
||||
fetchCardioLoads,
|
||||
fetchNightlyRecharges,
|
||||
fetchSleeps,
|
||||
fetchSpo2,
|
||||
getAuthorizationUrl,
|
||||
getPolarCredentials,
|
||||
@@ -99,6 +101,12 @@ describe("exchangeCode", () => {
|
||||
const tok = await exchangeCode("code123", CREDS);
|
||||
expect(tok.access_token).toBe("tok");
|
||||
expect(tok.x_user_id).toBe(42);
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
"https://polarremote.com/v2/oauth2/token",
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]![1]).toEqual(
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
const headers = init!.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe(
|
||||
@@ -120,9 +128,13 @@ describe("registerUser", () => {
|
||||
installFetchMock([{ status: 409, body: {} }]);
|
||||
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
|
||||
});
|
||||
it("succeeds on a 200", async () => {
|
||||
installFetchMock([{ status: 200, body: {} }]);
|
||||
it("posts to the documented registration path", async () => {
|
||||
const fetchMock = installFetchMock([{ status: 200, body: {} }]);
|
||||
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://www.polaraccesslink.com/v3/users",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
it("throws on a 500", async () => {
|
||||
installFetchMock([{ status: 500 }]);
|
||||
@@ -132,16 +144,55 @@ describe("registerUser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Polar AccessLink request paths", () => {
|
||||
it.each([
|
||||
{
|
||||
resource: "nightly recharge",
|
||||
request: () => fetchNightlyRecharges("tok"),
|
||||
url: "https://www.polaraccesslink.com/v3/users/nightly-recharge",
|
||||
},
|
||||
{
|
||||
resource: "sleep",
|
||||
request: () => fetchSleeps("tok"),
|
||||
url: "https://www.polaraccesslink.com/v3/users/sleep",
|
||||
},
|
||||
{
|
||||
resource: "daily activity",
|
||||
request: () => fetchActivities("tok"),
|
||||
url: "https://www.polaraccesslink.com/v3/users/activities",
|
||||
},
|
||||
{
|
||||
resource: "cardio load",
|
||||
request: () => fetchCardioLoads("tok"),
|
||||
url: "https://www.polaraccesslink.com/v3/users/cardio-load",
|
||||
},
|
||||
{
|
||||
resource: "SpO2",
|
||||
request: () => fetchSpo2("tok"),
|
||||
url: "https://www.polaraccesslink.com/v3/users/biosensing/spo2",
|
||||
},
|
||||
])("uses the documented GET path for $resource", async ({ request, url }) => {
|
||||
const fetchMock = installFetchMock([{ status: 204 }]);
|
||||
|
||||
await request();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchNightlyRecharges", () => {
|
||||
it("returns [] on a 204 No Content", async () => {
|
||||
installFetchMock([{ status: 204 }]);
|
||||
expect(await fetchNightlyRecharges("tok", "42")).toEqual([]);
|
||||
expect(await fetchNightlyRecharges("tok")).toEqual([]);
|
||||
});
|
||||
it("unwraps the recharges array", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: { recharges: [{ date: "2026-06-10" }] } },
|
||||
]);
|
||||
const r = await fetchNightlyRecharges("tok", "42");
|
||||
const r = await fetchNightlyRecharges("tok");
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0]!.date).toBe("2026-06-10");
|
||||
});
|
||||
@@ -364,8 +415,8 @@ describe("mapSleep", () => {
|
||||
describe("mapActivity — distance", () => {
|
||||
it("maps distance_from_steps to WALKING_RUNNING_DISTANCE in metres", () => {
|
||||
const a: PolarActivity = {
|
||||
date: "2026-06-10",
|
||||
"active-steps": 8000,
|
||||
start_time: "2026-06-10T00:00:00",
|
||||
steps: 8000,
|
||||
distance_from_steps: 4590.53,
|
||||
};
|
||||
const dist = mapActivity(a).find(
|
||||
@@ -393,39 +444,67 @@ describe("mapCardioLoad", () => {
|
||||
});
|
||||
|
||||
describe("mapSpo2", () => {
|
||||
it("maps blood_oxygen_percentage to OXYGEN_SATURATION (percent)", () => {
|
||||
const r: PolarSpo2 = { date: "2026-06-10", blood_oxygen_percentage: 96.4 };
|
||||
it("maps the documented SpO2 fields to an exact-timestamp reading", () => {
|
||||
const r: PolarSpo2 = {
|
||||
test_time: 1_781_059_600,
|
||||
blood_oxygen_percent: 96,
|
||||
};
|
||||
const mapped = mapSpo2(r);
|
||||
expect(mapped[0]?.type).toBe("OXYGEN_SATURATION");
|
||||
expect(mapped[0]?.value).toBe(96.4);
|
||||
expect(mapped[0]?.value).toBe(96);
|
||||
expect(mapped[0]?.unit).toBe("%");
|
||||
expect(mapped[0]?.measuredAt.toISOString()).toBe(
|
||||
"2026-06-10T02:46:40.000Z",
|
||||
);
|
||||
});
|
||||
it("rejects out-of-range readings", () => {
|
||||
expect(mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 0 })).toEqual(
|
||||
expect(mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 0 })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 120 }),
|
||||
mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 120 }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCardioLoads", () => {
|
||||
it("unwraps the cardio-loads array", async () => {
|
||||
it("returns the documented top-level cardio-load array", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: { "cardio-loads": [{ date: "2026-06-10" }] } },
|
||||
{ status: 200, body: [{ date: "2026-06-10", cardio_load: 123.45 }] },
|
||||
]);
|
||||
const r = await fetchCardioLoads("tok", "42");
|
||||
const r = await fetchCardioLoads("tok");
|
||||
expect(r).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchSpo2", () => {
|
||||
it("unwraps the tests array", async () => {
|
||||
it("returns the documented top-level SpO2 array", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: { tests: [{ date: "2026-06-10" }] } },
|
||||
{
|
||||
status: 200,
|
||||
body: [{ test_time: 1_781_059_600, blood_oxygen_percent: 96 }],
|
||||
},
|
||||
]);
|
||||
const r = await fetchSpo2("tok", "42");
|
||||
const r = await fetchSpo2("tok");
|
||||
expect(r).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchActivities", () => {
|
||||
it("returns the documented top-level activity array", async () => {
|
||||
installFetchMock([
|
||||
{
|
||||
status: 200,
|
||||
body: [
|
||||
{
|
||||
start_time: "2026-06-10T00:00:00",
|
||||
active_calories: 540.6,
|
||||
steps: 8123,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const r = await fetchActivities("tok");
|
||||
expect(r).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -433,9 +512,9 @@ describe("fetchSpo2", () => {
|
||||
describe("mapActivity", () => {
|
||||
it("maps steps and active energy to canonical MeasurementType enum values", () => {
|
||||
const a: PolarActivity = {
|
||||
date: "2026-06-10",
|
||||
"active-steps": 8123,
|
||||
"active-calories": 540.6,
|
||||
start_time: "2026-06-10T00:00:00",
|
||||
steps: 8123,
|
||||
active_calories: 540.6,
|
||||
calories: 2400,
|
||||
};
|
||||
const mapped = mapActivity(a);
|
||||
@@ -445,6 +524,9 @@ describe("mapActivity", () => {
|
||||
540.6,
|
||||
);
|
||||
});
|
||||
it("ignores an activity when Polar omits its optional start time", () => {
|
||||
expect(mapActivity({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POLAR_OAUTH_SCOPE", () => {
|
||||
|
||||
@@ -168,6 +168,18 @@ describe("syncUserPolar", () => {
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("authenticates all collection reads with the access token only", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
|
||||
await syncUserPolar("u1");
|
||||
|
||||
expect(fetchRechargesMock).toHaveBeenCalledWith("tok");
|
||||
expect(fetchSleepsMock).toHaveBeenCalledWith("tok");
|
||||
expect(fetchActivitiesMock).toHaveBeenCalledWith("tok");
|
||||
expect(fetchCardioLoadsMock).toHaveBeenCalledWith("tok");
|
||||
expect(fetchSpo2Mock).toHaveBeenCalledWith("tok");
|
||||
});
|
||||
|
||||
it("maps a recharge RECOVERY_SCORE row with source POLAR + stable externalId", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
fetchRechargesMock.mockResolvedValue([
|
||||
|
||||
+40
-50
@@ -13,9 +13,9 @@
|
||||
* - Polar access tokens DO NOT expire and there is NO refresh token. The
|
||||
* `User.polarRefreshTokenEncrypted` column stays null; `getValidToken`
|
||||
* never refreshes. A revoked grant surfaces as a 401 on the next data read.
|
||||
* - The token response carries `x_user_id` — Polar's numeric member id — which
|
||||
* every data path needs (`/v3/users/{userId}/...`). It is persisted as
|
||||
* `User.polarUserIdEncrypted` at connect time.
|
||||
* - The token response carries `x_user_id` — Polar's numeric member id. It
|
||||
* remains necessary for user registration and direct user-information
|
||||
* lookups, while collection reads identify the user from the access token.
|
||||
* - The client must register the user once (`POST /v3/users`) before data
|
||||
* reads work; a 409 from registration means "already registered" and is
|
||||
* treated as success.
|
||||
@@ -198,11 +198,11 @@ export interface PolarSleep {
|
||||
heart_rate_samples?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** One Polar daily-activity summary. */
|
||||
/** One current AccessLink daily-activity summary. */
|
||||
export interface PolarActivity {
|
||||
date: string;
|
||||
"active-calories"?: number | null;
|
||||
"active-steps"?: number | null;
|
||||
start_time?: string;
|
||||
active_calories?: number | null;
|
||||
steps?: number | null;
|
||||
calories?: number | null;
|
||||
/** Estimated walking/running distance derived from steps, in METRES. */
|
||||
distance_from_steps?: number | null;
|
||||
@@ -223,11 +223,11 @@ export interface PolarCardioLoad {
|
||||
cardio_load_ratio?: number | null;
|
||||
}
|
||||
|
||||
/** One Polar SpO2 (Elixir pulse-ox) test result. `blood_oxygen_percentage` is
|
||||
* the recorded SpO2 reading in percent (0..100). */
|
||||
/** One Polar SpO2 (Elixir pulse-ox) test result. `test_time` is a Unix
|
||||
* timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */
|
||||
export interface PolarSpo2 {
|
||||
date: string;
|
||||
blood_oxygen_percentage?: number | null;
|
||||
test_time?: number;
|
||||
blood_oxygen_percent?: number | null;
|
||||
}
|
||||
|
||||
interface CollectionResult<T> {
|
||||
@@ -238,7 +238,7 @@ async function fetchCollection<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
verb: string,
|
||||
recordsKey: string,
|
||||
recordsKey?: string,
|
||||
): Promise<T[]> {
|
||||
const start = performance.now();
|
||||
const res = await safeFetch(`${POLAR_API_BASE}${path}`, {
|
||||
@@ -267,73 +267,63 @@ async function fetchCollection<T>(
|
||||
// 204 No Content — a window with no records. Polar returns an empty body.
|
||||
if (res.status === 204) return [];
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
CollectionResult<T> | Record<string, T[]> | null;
|
||||
| CollectionResult<T>
|
||||
| Record<string, T[]>
|
||||
| T[]
|
||||
| null;
|
||||
if (!json) return [];
|
||||
if (Array.isArray(json)) return json;
|
||||
if (!recordsKey) return [];
|
||||
const list = (json as Record<string, unknown>)[recordsKey];
|
||||
return Array.isArray(list) ? (list as T[]) : [];
|
||||
}
|
||||
|
||||
export function fetchNightlyRecharges(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarNightlyRecharge[]> {
|
||||
return fetchCollection<PolarNightlyRecharge>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/nightly-recharge`,
|
||||
"/v3/users/nightly-recharge",
|
||||
accessToken,
|
||||
"fetchNightlyRecharges",
|
||||
"recharges",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchSleeps(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarSleep[]> {
|
||||
export function fetchSleeps(accessToken: string): Promise<PolarSleep[]> {
|
||||
return fetchCollection<PolarSleep>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/sleep`,
|
||||
"/v3/users/sleep",
|
||||
accessToken,
|
||||
"fetchSleeps",
|
||||
"nights",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchActivities(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarActivity[]> {
|
||||
export function fetchActivities(accessToken: string): Promise<PolarActivity[]> {
|
||||
return fetchCollection<PolarActivity>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/activity-transactions`,
|
||||
"/v3/users/activities",
|
||||
accessToken,
|
||||
"fetchActivities",
|
||||
"activity-log",
|
||||
);
|
||||
}
|
||||
|
||||
/** Training Load Pro collection — `cardio_load` strain figures for the recent
|
||||
* window. Records live under the `cardio-loads` key. */
|
||||
* window. */
|
||||
export function fetchCardioLoads(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarCardioLoad[]> {
|
||||
return fetchCollection<PolarCardioLoad>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/cardio-load`,
|
||||
"/v3/users/cardio-load",
|
||||
accessToken,
|
||||
"fetchCardioLoads",
|
||||
"cardio-loads",
|
||||
);
|
||||
}
|
||||
|
||||
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. Records
|
||||
* live under the `tests` key. */
|
||||
export function fetchSpo2(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarSpo2[]> {
|
||||
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */
|
||||
export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
|
||||
return fetchCollection<PolarSpo2>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/spo2-tests`,
|
||||
"/v3/users/biosensing/spo2",
|
||||
accessToken,
|
||||
"fetchSpo2",
|
||||
"tests",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -567,16 +557,15 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map one Polar daily-activity record: `ACTIVITY_STEPS` +
|
||||
* `ACTIVE_ENERGY_BURNED` where Polar reports them. Steps live under
|
||||
* `active-steps`; the ACTIVE energy portion under `active-calories` (NOT the
|
||||
* total `calories`, which includes BMR — matching the Fitbit active-only
|
||||
* convention). */
|
||||
/** Map one current AccessLink daily-activity record: `steps` +
|
||||
* `active_calories` where Polar reports them. The active energy portion is
|
||||
* used instead of total `calories`, which includes BMR. */
|
||||
export function mapActivity(a: PolarActivity): MappedMeasurement[] {
|
||||
const measuredAt = canonicalDailyTimestamp(a.date);
|
||||
if (typeof a.start_time !== "string") return [];
|
||||
const measuredAt = canonicalDailyTimestamp(a.start_time.slice(0, 10));
|
||||
if (Number.isNaN(measuredAt.getTime())) return [];
|
||||
const out: MappedMeasurement[] = [];
|
||||
const steps = a["active-steps"];
|
||||
const steps = a.steps;
|
||||
if (typeof steps === "number" && steps >= 0) {
|
||||
out.push({
|
||||
type: "ACTIVITY_STEPS",
|
||||
@@ -586,7 +575,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] {
|
||||
fieldTag: "steps",
|
||||
});
|
||||
}
|
||||
const activeCalories = a["active-calories"];
|
||||
const activeCalories = a.active_calories;
|
||||
if (typeof activeCalories === "number" && activeCalories >= 0) {
|
||||
out.push({
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
@@ -630,12 +619,13 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Map one Polar SpO2 test result: `blood_oxygen_percentage` →
|
||||
* `OXYGEN_SATURATION` (percent, 0..100). */
|
||||
/** Map one Polar SpO2 test result: `blood_oxygen_percent` →
|
||||
* `OXYGEN_SATURATION` at the test's exact Unix timestamp. */
|
||||
export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
|
||||
const measuredAt = canonicalDailyTimestamp(r.date);
|
||||
if (typeof r.test_time !== "number") return [];
|
||||
const measuredAt = new Date(r.test_time * 1000);
|
||||
if (Number.isNaN(measuredAt.getTime())) return [];
|
||||
const pct = r.blood_oxygen_percentage;
|
||||
const pct = r.blood_oxygen_percent;
|
||||
if (typeof pct !== "number" || pct <= 0 || pct > 100) return [];
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -115,11 +115,11 @@ export async function syncUserPolar(userId: string): Promise<number> {
|
||||
try {
|
||||
const [recharges, sleeps, activities, cardioLoads, spo2Tests] =
|
||||
await Promise.all([
|
||||
fetchNightlyRecharges(conn.accessToken, conn.polarUserId),
|
||||
fetchSleeps(conn.accessToken, conn.polarUserId),
|
||||
fetchActivities(conn.accessToken, conn.polarUserId),
|
||||
fetchCardioLoads(conn.accessToken, conn.polarUserId),
|
||||
fetchSpo2(conn.accessToken, conn.polarUserId),
|
||||
fetchNightlyRecharges(conn.accessToken),
|
||||
fetchSleeps(conn.accessToken),
|
||||
fetchActivities(conn.accessToken),
|
||||
fetchCardioLoads(conn.accessToken),
|
||||
fetchSpo2(conn.accessToken),
|
||||
]);
|
||||
for (const r of recharges) {
|
||||
readings.push(...toUpsert(mapNightlyRecharge(r), "recharge"));
|
||||
|
||||
Reference in new issue
Block a user