From 574b860eb4604da1c22f11b904f1171ff92fded4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:07:42 +0200 Subject: [PATCH] feat(polar): complete the AccessLink pull surface Polar read three collections off the wire and dropped several useful signals at the mapper. Close the gaps, keeping every value server-authoritative and canonical-unit: - Sleep now follows the END-instant convention the canonical night-grouper depends on. mapSleep reconstructs an ordered, contiguous CORE/DEEP/REM timeline from the parsed sleep_start_time / sleep_end_time, stamping each segment at its own end instant with an indexed externalId, plus an IN_BED envelope and the score at the sleep end. This restores the hypnogram (the stages no longer collapse onto one midnight-UTC instant) and fixes wake-day mis-attribution for negative-UTC users. Records without a usable window fall back to the prior midnight anchor. Synthetic ordering is flagged reconstructed. - ans_charge maps to ANS_CHARGE (through-as-is; a baseline-relative deviation that can run negative). - distance_from_steps maps to WALKING_RUNNING_DISTANCE (metres), filling the Polar distance-ladder slot that previously had no producer. - New fetchers + mappers for Training Load Pro (cardio_load -> CARDIO_LOAD) and the Elixir SpO2 collection (-> OXYGEN_SATURATION), with Polar added to the SpO2 source ladder below the established straps. No strap/profile weight is fetched; egress stays on safeFetch. --- src/lib/polar/__tests__/client.test.ts | 159 ++++++++++++++++ src/lib/polar/__tests__/sync.test.ts | 48 +++++ src/lib/polar/client.ts | 242 +++++++++++++++++++++++-- src/lib/polar/sync.ts | 43 +++-- src/lib/validations/source-priority.ts | 5 +- 5 files changed, 471 insertions(+), 26 deletions(-) diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 78dde0ff3..d68ae79f6 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -3,17 +3,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { POLAR_OAUTH_SCOPE, exchangeCode, + fetchCardioLoads, fetchNightlyRecharges, + fetchSpo2, getAuthorizationUrl, getPolarCredentials, getPolarRedirectUri, mapActivity, + mapCardioLoad, mapNightlyRecharge, mapSleep, + mapSpo2, registerUser, type PolarActivity, + type PolarCardioLoad, type PolarNightlyRecharge, type PolarSleep, + type PolarSpo2, } from "../client"; import { PolarApiError } from "../response-classifier"; @@ -158,6 +164,30 @@ describe("mapNightlyRecharge", () => { expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.2); }); + it("maps ans_charge to ANS_CHARGE (through-as-is, negative allowed)", () => { + const mapped = mapNightlyRecharge({ + date: "2026-06-10", + ans_charge: -3.42, + }); + const ans = mapped.find((m) => m.type === "ANS_CHARGE"); + expect(ans?.value).toBe(-3.42); + expect(ans?.unit).toBe("score"); + expect(ans?.fieldTag).toBe("ans_charge"); + }); + + it("keeps a zero ans_charge (baseline is a valid reading)", () => { + const mapped = mapNightlyRecharge({ date: "2026-06-10", ans_charge: 0 }); + expect(mapped.find((m) => m.type === "ANS_CHARGE")?.value).toBe(0); + }); + + it("omits ANS_CHARGE when ans_charge is absent", () => { + const mapped = mapNightlyRecharge({ + date: "2026-06-10", + nightly_recharge_status: 3, + }); + expect(mapped.find((m) => m.type === "ANS_CHARGE")).toBeUndefined(); + }); + it("rescales status 1 -> 0", () => { const mapped = mapNightlyRecharge({ date: "2026-06-10", @@ -175,6 +205,8 @@ describe("mapSleep", () => { it("maps per-stage durations s->min and the sleep score", () => { const s: PolarSleep = { date: "2026-06-10", + sleep_start_time: "2026-06-09T23:00:00+02:00", + sleep_end_time: "2026-06-10T07:00:00+02:00", light_sleep: 3600, deep_sleep: 1800, rem_sleep: 5400, @@ -187,6 +219,133 @@ describe("mapSleep", () => { expect(mapped.find((m) => m.sleepStage === "REM")?.value).toBe(90); expect(mapped.find((m) => m.type === "SLEEP_PERFORMANCE")?.value).toBe(82); }); + + it("lays a reconstructed END-instant timeline from the sleep window", () => { + const start = "2026-06-09T23:00:00+02:00"; // 21:00 UTC + const s: PolarSleep = { + date: "2026-06-10", + sleep_start_time: start, + sleep_end_time: "2026-06-10T07:00:00+02:00", // 05:00 UTC + light_sleep: 3600, // 60 min + deep_sleep: 1800, // 30 min + rem_sleep: 5400, // 90 min + sleep_score: 82, + }; + const mapped = mapSleep(s); + const onset = Date.parse(start); + const core = mapped.find((m) => m.sleepStage === "CORE")!; + const deep = mapped.find((m) => m.sleepStage === "DEEP")!; + const rem = mapped.find((m) => m.sleepStage === "REM")!; + + // Each segment ends at its own instant, laid contiguously from onset: + // CORE ends at +60m, DEEP at +90m, REM at +180m. + expect(core.measuredAt.getTime()).toBe(onset + 60 * 60_000); + expect(deep.measuredAt.getTime()).toBe(onset + 90 * 60_000); + expect(rem.measuredAt.getTime()).toBe(onset + 180 * 60_000); + + // Distinct instants → an ordered hypnogram (the F1 fix), not three + // coincident midnight-UTC points. + const instants = new Set( + [core, deep, rem].map((m) => m.measuredAt.getTime()), + ); + expect(instants.size).toBe(3); + + // Reconstructed flag + indexed externalId keep the segment rows distinct. + expect(core.reconstructed).toBe(true); + expect(core.externalId).toBe("sleep:2026-06-10:seg:sleep_core:0"); + expect(rem.externalId).toBe("sleep:2026-06-10:seg:sleep_rem:2"); + + // IN_BED envelope + score stamp at the sleep END instant. + const endMs = Date.parse("2026-06-10T07:00:00+02:00"); + const inBed = mapped.find((m) => m.sleepStage === "IN_BED")!; + expect(inBed.measuredAt.getTime()).toBe(endMs); + expect(inBed.value).toBe(480); // 8 h + expect( + mapped.find((m) => m.type === "SLEEP_PERFORMANCE")!.measuredAt.getTime(), + ).toBe(endMs); + }); + + it("falls back to a midnight-UTC anchor when the window is missing", () => { + const mapped = mapSleep({ + date: "2026-06-10", + light_sleep: 3600, + sleep_score: 80, + }); + const core = mapped.find((m) => m.sleepStage === "CORE")!; + expect(core.measuredAt.toISOString()).toBe("2026-06-10T00:00:00.000Z"); + expect(core.reconstructed).toBeUndefined(); + // No IN_BED envelope without a window. + expect(mapped.find((m) => m.sleepStage === "IN_BED")).toBeUndefined(); + }); +}); + +describe("mapActivity — distance", () => { + it("maps distance_from_steps to WALKING_RUNNING_DISTANCE in metres", () => { + const a: PolarActivity = { + date: "2026-06-10", + "active-steps": 8000, + distance_from_steps: 4590.53, + }; + const dist = mapActivity(a).find( + (m) => m.type === "WALKING_RUNNING_DISTANCE", + ); + expect(dist?.value).toBe(4590.53); + expect(dist?.unit).toBe("meters"); + }); +}); + +describe("mapCardioLoad", () => { + it("maps cardio_load to CARDIO_LOAD", () => { + const c: PolarCardioLoad = { date: "2026-06-10", cardio_load: 123.45 }; + const mapped = mapCardioLoad(c); + expect(mapped[0]?.type).toBe("CARDIO_LOAD"); + expect(mapped[0]?.value).toBe(123.45); + expect(mapped[0]?.unit).toBe("score"); + }); + it("keeps a zero cardio_load and skips a missing one", () => { + expect(mapCardioLoad({ date: "2026-06-10", cardio_load: 0 })[0]?.value).toBe( + 0, + ); + expect(mapCardioLoad({ date: "2026-06-10" })).toEqual([]); + }); +}); + +describe("mapSpo2", () => { + it("maps blood_oxygen_percentage to OXYGEN_SATURATION (percent)", () => { + const r: PolarSpo2 = { date: "2026-06-10", blood_oxygen_percentage: 96.4 }; + const mapped = mapSpo2(r); + expect(mapped[0]?.type).toBe("OXYGEN_SATURATION"); + expect(mapped[0]?.value).toBe(96.4); + expect(mapped[0]?.unit).toBe("%"); + }); + it("rejects out-of-range readings", () => { + expect(mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 0 })).toEqual( + [], + ); + expect( + mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 120 }), + ).toEqual([]); + }); +}); + +describe("fetchCardioLoads", () => { + it("unwraps the cardio-loads array", async () => { + installFetchMock([ + { status: 200, body: { "cardio-loads": [{ date: "2026-06-10" }] } }, + ]); + const r = await fetchCardioLoads("tok", "42"); + expect(r).toHaveLength(1); + }); +}); + +describe("fetchSpo2", () => { + it("unwraps the tests array", async () => { + installFetchMock([ + { status: 200, body: { tests: [{ date: "2026-06-10" }] } }, + ]); + const r = await fetchSpo2("tok", "42"); + expect(r).toHaveLength(1); + }); }); describe("mapActivity", () => { diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index dda649bf6..2e0ecdd94 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -5,6 +5,8 @@ const { fetchRechargesMock, fetchSleepsMock, fetchActivitiesMock, + fetchCardioLoadsMock, + fetchSpo2Mock, upsertMock, recordSuccessMock, recordFailureMock, @@ -15,6 +17,8 @@ const { fetchRechargesMock: vi.fn(), fetchSleepsMock: vi.fn(), fetchActivitiesMock: vi.fn(), + fetchCardioLoadsMock: vi.fn(), + fetchSpo2Mock: vi.fn(), upsertMock: vi.fn(), recordSuccessMock: vi.fn(), recordFailureMock: vi.fn(), @@ -53,6 +57,8 @@ vi.mock("../client", async (importOriginal) => { fetchNightlyRecharges: fetchRechargesMock, fetchSleeps: fetchSleepsMock, fetchActivities: fetchActivitiesMock, + fetchCardioLoads: fetchCardioLoadsMock, + fetchSpo2: fetchSpo2Mock, }; }); @@ -66,6 +72,8 @@ beforeEach(() => { fetchRechargesMock.mockReset(); fetchSleepsMock.mockReset(); fetchActivitiesMock.mockReset(); + fetchCardioLoadsMock.mockReset(); + fetchSpo2Mock.mockReset(); upsertMock.mockReset().mockResolvedValue({}); recordSuccessMock.mockReset().mockResolvedValue(undefined); recordFailureMock.mockReset().mockResolvedValue(undefined); @@ -74,6 +82,8 @@ beforeEach(() => { fetchRechargesMock.mockResolvedValue([]); fetchSleepsMock.mockResolvedValue([]); fetchActivitiesMock.mockResolvedValue([]); + fetchCardioLoadsMock.mockResolvedValue([]); + fetchSpo2Mock.mockResolvedValue([]); }); afterEach(() => vi.clearAllMocks()); @@ -105,6 +115,44 @@ describe("syncUserPolar", () => { expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar"); }); + it("maps a cardio-load row with source POLAR + stable externalId", async () => { + getConnMock.mockResolvedValue(CONN); + fetchCardioLoadsMock.mockResolvedValue([ + { date: "2026-06-10", cardio_load: 123.45 }, + ]); + const imported = await syncUserPolar("u1"); + expect(imported).toBe(1); + const arg = upsertMock.mock.calls[0]![0]; + expect(arg.where.userId_type_source_externalId).toMatchObject({ + type: "CARDIO_LOAD", + source: "POLAR", + externalId: "cardioload:2026-06-10:cardio_load", + }); + expect(arg.create.value).toBe(123.45); + }); + + it("keeps reconstructed sleep segments distinct under their indexed externalId", async () => { + getConnMock.mockResolvedValue(CONN); + fetchSleepsMock.mockResolvedValue([ + { + date: "2026-06-10", + sleep_start_time: "2026-06-09T23:00:00+02:00", + sleep_end_time: "2026-06-10T07:00:00+02:00", + light_sleep: 3600, + deep_sleep: 1800, + rem_sleep: 5400, + }, + ]); + await syncUserPolar("u1"); + const externalIds = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.externalId, + ); + expect(externalIds).toContain("sleep:2026-06-10:seg:sleep_core:0"); + expect(externalIds).toContain("sleep:2026-06-10:seg:sleep_rem:2"); + // The several segment rows stay distinct (no collision). + expect(new Set(externalIds).size).toBe(externalIds.length); + }); + it("is idempotent — a re-sync upserts on the same externalId", async () => { getConnMock.mockResolvedValue(CONN); fetchRechargesMock.mockResolvedValue([ diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 322b0a160..1de24b073 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -4,8 +4,8 @@ * * Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled * fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection - * fetchers, and a single source-of-truth field→Measurement mapping - * (`POLAR_FIELD_MAP`). + * fetchers, and per-resource field→Measurement mappers (self-documenting; there + * is no separate reference constant). * * Polar specifics that differ from WHOOP: * - The token endpoint uses HTTP Basic auth (`base64(client_id:client_secret)`) @@ -33,9 +33,11 @@ export interface PolarCredentials { clientSecret: string; } -/** Resolve the env-configured shared OAuth credentials (F4 = full OAuth from - * env, not per-user BYO keys). Returns null when unconfigured so the connect - * route can surface a clean "integration disabled" message. */ +/** Resolve the env-configured shared OAuth credentials. This is the fallback + * the per-user BYO-key resolver (`getPolarClientCredentials`) drops to when a + * user has not stored their own AccessLink app id/secret. Returns null when + * unconfigured so the connect route can surface a clean "integration disabled" + * message. */ export function getPolarCredentials(): PolarCredentials | null { const clientId = process.env.POLAR_CLIENT_ID; const clientSecret = process.env.POLAR_CLIENT_SECRET; @@ -201,6 +203,30 @@ export interface PolarActivity { "active-calories"?: number | null; "active-steps"?: number | null; calories?: number | null; + /** Estimated walking/running distance derived from steps, in METRES. */ + distance_from_steps?: number | null; +} + +/** + * One Polar Training Load Pro record (cardio-load collection). `cardio_load` is + * the session/day cardiovascular load figure — Polar's WHOOP-day-strain + * analogue. `strain` / `tolerance` / `cardio_load_ratio` describe the + * acute-vs-chronic balance; only `cardio_load` is mapped today (the others are + * derived ratios we don't surface yet). + */ +export interface PolarCardioLoad { + date: string; + cardio_load?: number | null; + strain?: number | null; + tolerance?: number | null; + 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). */ +export interface PolarSpo2 { + date: string; + blood_oxygen_percentage?: number | null; } interface CollectionResult { @@ -284,6 +310,34 @@ export function fetchActivities( ); } +/** Training Load Pro collection — `cardio_load` strain figures for the recent + * window. Records live under the `cardio-loads` key. */ +export function fetchCardioLoads( + accessToken: string, + userId: string, +): Promise { + return fetchCollection( + `/v3/users/${encodeURIComponent(userId)}/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 { + return fetchCollection( + `/v3/users/${encodeURIComponent(userId)}/spo2-tests`, + accessToken, + "fetchSpo2", + "tests", + ); +} + // ─── Field → Measurement mapping ─────────────────────────────── const SEC_TO_MIN = 1 / 60; @@ -293,14 +347,31 @@ function round2(n: number): number { } /** A single mapped reading destined for one `Measurement` row. The sync layer - * stamps `source = POLAR` + `externalId = :`. */ + * stamps `source = POLAR` + `externalId = ::` unless + * the mapper supplies its own full `externalId` (the per-segment sleep rows + * need an index in the key). */ export interface MappedMeasurement { type: string; value: number; unit: string; measuredAt: Date; fieldTag: string; - sleepStage?: "CORE" | "DEEP" | "REM"; + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; + /** + * Full externalId for rows the mapper keys itself rather than letting the + * sync layer build `::`. The reconstructed sleep + * segments (one row per synthetic span) carry an index so the several rows of + * one stage stay distinct under `userId_type_source_externalId`. When set the + * sync layer uses it verbatim. + */ + externalId?: string; + /** + * `true` on per-segment sleep rows whose ORDER is synthesised. When Polar + * gives only per-stage duration totals (no per-stage onset timestamps), the + * timeline is reconstructed in a fixed physiological order; the UI labels such + * a night as an approximate layout and never presents it as measured timing. + */ + reconstructed?: boolean; } /** A `nightly_recharge_status` is Polar's 1–6 recovery band; map it to a @@ -360,21 +431,119 @@ export function mapNightlyRecharge(r: PolarNightlyRecharge): MappedMeasurement[] fieldTag: "resp_rate", }); } + // ANS charge — the HRV-based autonomic-nervous-system component of Nightly + // Recharge, Polar's primary differentiated recovery signal. It is a deviation + // around the personal baseline (can be negative), so map it through as-is + // rather than clamping like the 1–6 recovery band. `0` is a valid reading + // (baseline), so guard on `number` only, not truthiness. + if (typeof r.ans_charge === "number") { + out.push({ + type: "ANS_CHARGE", + value: round2(r.ans_charge), + unit: "score", + measuredAt, + fieldTag: "ans_charge", + }); + } return out; } -/** Map one Polar Sleep record: per-stage `SLEEP_DURATION` (s→min) + the - * `SLEEP_SCORE` percentage where present. */ +/** + * Map one Polar Sleep record into per-stage `SLEEP_DURATION` rows + the + * `SLEEP_SCORE` percentage. + * + * `measuredAt` follows the END-instant convention every other sleep integration + * uses (WHOOP/Withings/Fitbit) and that the canonical night-grouper + * (`analytics/sleep-night.ts`) depends on: each stage segment is stamped at its + * own END instant so the hypnogram has an ordered internal timeline and the + * wake-day key resolves to the right LOCAL day even for negative-UTC users. + * + * Polar's sleep collection carries only per-stage DURATION totals plus the + * night's `sleep_start_time` / `sleep_end_time` (ISO-8601 with local offset) — + * no per-stage onset timestamps. So we RECONSTRUCT an ordered, contiguous + * timeline exactly like WHOOP: lay the asleep stages back-to-back from + * `sleep_start_time` in a fixed physiological order (CORE → DEEP → REM), + * emitting one timed row per segment ending at the running cursor, and stamp the + * `IN_BED` envelope + the score row at `sleep_end_time`. The ORDER is synthetic, + * so every reconstructed segment is flagged `reconstructed: true` and keyed by + * an indexed externalId so the several rows of one night stay distinct. + * + * If the night carries no usable start/end (older records, missing fields) we + * fall back to a midnight-UTC anchor and emit untimed stage rows — degraded but + * never wrong-day for the common UTC-positive case, matching the pre-fix shape. + */ export function mapSleep(s: PolarSleep): MappedMeasurement[] { - const measuredAt = new Date(`${s.date}T00:00:00.000Z`); - if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; - const stages: Array<[number | null | undefined, MappedMeasurement["sleepStage"], string]> = [ + const startMs = s.sleep_start_time ? Date.parse(s.sleep_start_time) : NaN; + const endMs = s.sleep_end_time ? Date.parse(s.sleep_end_time) : NaN; + const haveWindow = + Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs; + + const stages: Array< + [number | null | undefined, NonNullable, string] + > = [ [s.light_sleep, "CORE", "sleep_core"], [s.deep_sleep, "DEEP", "sleep_deep"], [s.rem_sleep, "REM", "sleep_rem"], ]; + + if (haveWindow) { + // Reconstructed timeline: lay each asleep stage contiguously from onset, one + // timed row per segment ending at the running cursor. The order is + // synthetic (Polar gives no per-stage onset), so flag every segment. + let cursor = startMs; + let segIndex = 0; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec !== "number" || sec <= 0) continue; + const segEnd = cursor + sec * 1000; + out.push({ + type: "SLEEP_DURATION", + value: round2(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt: new Date(segEnd), + fieldTag, + // Indexed externalId keeps the several segment rows of one night + // distinct under userId_type_source_externalId. + externalId: `sleep:${s.date}:seg:${fieldTag}:${segIndex}`, + sleepStage: stage, + reconstructed: true, + }); + cursor = segEnd; + segIndex += 1; + } + + // IN_BED — single envelope row over the whole sleep window, stamped at the + // sleep END so the in-bed reader resolves the span back to [start, end]. + const inBedSec = (endMs - startMs) / 1000; + if (inBedSec > 0) { + out.push({ + type: "SLEEP_DURATION", + value: round2(inBedSec * SEC_TO_MIN), + unit: "minutes", + measuredAt: new Date(endMs), + fieldTag: "sleep_in_bed", + sleepStage: "IN_BED", + }); + } + + if (typeof s.sleep_score === "number") { + out.push({ + type: "SLEEP_PERFORMANCE", + value: round2(s.sleep_score), + unit: "%", + measuredAt: new Date(endMs), + fieldTag: "sleep_score", + }); + } + return out; + } + + // Fallback: no usable window. Anchor at midnight UTC of the wake date and emit + // untimed stage rows (the pre-v1.17.1 shape) so a record missing the window + // fields still contributes a night total. + const measuredAt = new Date(`${s.date}T00:00:00.000Z`); + if (Number.isNaN(measuredAt.getTime())) return []; for (const [sec, stage, fieldTag] of stages) { if (typeof sec === "number" && sec >= 0) { out.push({ @@ -428,7 +597,54 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] { fieldTag: "active_energy", }); } + // Distance derived from steps, already in METRES (canonical SI for + // WALKING_RUNNING_DISTANCE). Fills the POLAR slot the distance ladder already + // ranks — previously a phantom entry with no producer. + const distance = a.distance_from_steps; + if (typeof distance === "number" && distance >= 0) { + out.push({ + type: "WALKING_RUNNING_DISTANCE", + value: round2(distance), + unit: "meters", + measuredAt, + fieldTag: "distance", + }); + } return out; } -/** Field→Measurement reference table (mirror of the mappers above). */ +/** Map one Polar Training Load Pro record: `cardio_load` → `CARDIO_LOAD` + * (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so + * guard on `number` only. */ +export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { + const measuredAt = new Date(`${c.date}T00:00:00.000Z`); + if (Number.isNaN(measuredAt.getTime())) return []; + if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; + return [ + { + type: "CARDIO_LOAD", + value: round2(c.cardio_load), + unit: "score", + measuredAt, + fieldTag: "cardio_load", + }, + ]; +} + +/** Map one Polar SpO2 test result: `blood_oxygen_percentage` → + * `OXYGEN_SATURATION` (percent, 0..100). */ +export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { + const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + if (Number.isNaN(measuredAt.getTime())) return []; + const pct = r.blood_oxygen_percentage; + if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; + return [ + { + type: "OXYGEN_SATURATION", + value: round2(pct), + unit: "%", + measuredAt, + fieldTag: "spo2", + }, + ]; +} diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 38ec71e69..1820792f4 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -1,10 +1,11 @@ /** * v1.17.0 (F4) — Polar AccessLink sync. * - * Pulls Nightly Recharge (→ RECOVERY_SCORE / HRV / RHR / respiratory rate), - * sleep (per-stage durations + sleep score), and daily activity (steps + - * active energy) for one connected user, mapping each into `Measurement` rows - * tagged `source = POLAR`. + * Pulls Nightly Recharge (→ RECOVERY_SCORE / ANS_CHARGE / HRV / RHR / + * respiratory rate), sleep (per-stage durations on a reconstructed END-instant + * timeline + sleep score), daily activity (steps + active energy + distance), + * Training Load Pro (→ CARDIO_LOAD), and SpO2 (→ OXYGEN_SATURATION) for one + * connected user, mapping each into `Measurement` rows tagged `source = POLAR`. * * Token model: Polar tokens do not expire and have no refresh path, so there is * no `getValidToken` refresh dance (the WHOOP shape). A revoked grant surfaces @@ -36,11 +37,15 @@ import { import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { fetchActivities, + fetchCardioLoads, fetchNightlyRecharges, fetchSleeps, + fetchSpo2, mapActivity, + mapCardioLoad, mapNightlyRecharge, mapSleep, + mapSpo2, type MappedMeasurement, } from "./client"; import { getPolarConnection } from "./credentials"; @@ -58,7 +63,7 @@ export interface PolarMeasurementUpsert { unit: string; measuredAt: Date; externalId: string; - sleepStage?: "CORE" | "DEEP" | "REM" | null; + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED" | null; } function toUpsert( @@ -70,8 +75,15 @@ function toUpsert( value: m.value, unit: m.unit, measuredAt: m.measuredAt, + // Reconstructed sleep segments supply their own indexed externalId so the + // several rows of one night stay distinct; everything else keys on // `::` — stable across re-syncs of the same day. - externalId: `${resourcePrefix}:${m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`, + // The date is read off `measuredAt` for untimed rows (midnight-UTC + // anchored), so a mapper-supplied externalId is honoured verbatim to avoid + // the timed sleep instants drifting the date slice. + externalId: + m.externalId ?? + `${resourcePrefix}:${m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, })); } @@ -87,11 +99,14 @@ export async function syncUserPolar(userId: string): Promise { const readings: PolarMeasurementUpsert[] = []; try { - const [recharges, sleeps, activities] = await Promise.all([ - fetchNightlyRecharges(conn.accessToken, conn.polarUserId), - fetchSleeps(conn.accessToken, conn.polarUserId), - fetchActivities(conn.accessToken, conn.polarUserId), - ]); + 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), + ]); for (const r of recharges) { readings.push(...toUpsert(mapNightlyRecharge(r), "recharge")); } @@ -101,6 +116,12 @@ export async function syncUserPolar(userId: string): Promise { for (const a of activities) { readings.push(...toUpsert(mapActivity(a), "activity")); } + for (const c of cardioLoads) { + readings.push(...toUpsert(mapCardioLoad(c), "cardioload")); + } + for (const t of spo2Tests) { + readings.push(...toUpsert(mapSpo2(t), "spo2")); + } } catch (err) { await recordSyncFailure({ userId, diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index a6997a775..f613f488f 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -261,8 +261,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], // Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second, - // Fitbit third, Oura's ring fourth. - spo2: ["WITHINGS", "WHOOP", "FITBIT", "OURA", "APPLE_HEALTH", "MANUAL"], + // Fitbit third, Oura's ring fourth. v1.17.1 — Polar Elixir pulse-ox slots + // below the established straps and above the iPhone-relay / manual sources. + spo2: ["WITHINGS", "WHOOP", "FITBIT", "OURA", "POLAR", "APPLE_HEALTH", "MANUAL"], vo2Max: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "MANUAL"], // v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the // primary skin-temperature sensor; WHOOP's strap is second, Fitbit third,