mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
fd39580ee6
commit
574b860eb4
5 files changed
+471
-26
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user