diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 6f8fbfe70..69b2248ae 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -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; 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", () => { diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index b80055b53..9b717ee72 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -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([ diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 21f5c3fdf..215b984e1 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -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; } -/** 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 { @@ -238,7 +238,7 @@ async function fetchCollection( path: string, accessToken: string, verb: string, - recordsKey: string, + recordsKey?: string, ): Promise { const start = performance.now(); const res = await safeFetch(`${POLAR_API_BASE}${path}`, { @@ -267,73 +267,63 @@ async function fetchCollection( // 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 | Record | null; + | CollectionResult + | Record + | T[] + | null; if (!json) return []; + if (Array.isArray(json)) return json; + if (!recordsKey) return []; const list = (json as Record)[recordsKey]; return Array.isArray(list) ? (list as T[]) : []; } export function fetchNightlyRecharges( accessToken: string, - userId: string, ): Promise { return fetchCollection( - `/v3/users/${encodeURIComponent(userId)}/nightly-recharge`, + "/v3/users/nightly-recharge", accessToken, "fetchNightlyRecharges", "recharges", ); } -export function fetchSleeps( - accessToken: string, - userId: string, -): Promise { +export function fetchSleeps(accessToken: string): Promise { return fetchCollection( - `/v3/users/${encodeURIComponent(userId)}/sleep`, + "/v3/users/sleep", accessToken, "fetchSleeps", "nights", ); } -export function fetchActivities( - accessToken: string, - userId: string, -): Promise { +export function fetchActivities(accessToken: string): Promise { return fetchCollection( - `/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 { return fetchCollection( - `/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 { +/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */ +export function fetchSpo2(accessToken: string): Promise { return fetchCollection( - `/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 [ { diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index e3a4c5283..f43c184d9 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -115,11 +115,11 @@ export async function syncUserPolar(userId: string): Promise { 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"));