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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:07:42 +02:00
1 parent fd39580ee6
commit 574b860eb4
5 files changed
+471 -26

No files matched your search

+159
View File
@@ -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", () => {
+48
View File
@@ -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([
+229 -13
View File
@@ -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<T> {
@@ -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<PolarCardioLoad[]> {
return fetchCollection<PolarCardioLoad>(
`/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<PolarSpo2[]> {
return fetchCollection<PolarSpo2>(
`/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 = <date>:<fieldTag>`. */
* stamps `source = POLAR` + `externalId = <resource>:<date>:<fieldTag>` 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 `<resource>:<date>:<fieldTag>`. 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<MappedMeasurement["sleepStage"]>, 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",
},
];
}
+32 -11
View File
@@ -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
// `<resource>:<date>:<fieldTag>` — 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<number> {
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<number> {
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,
+3 -2
View File
@@ -261,8 +261,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
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,