merge(polar): pull ANS charge, cardio load, SpO2, distance; fix sleep timeline; invalid_grant + reconcile

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:13:01 +02:00
commit 412b22e68e
28 files changed
+936 -32

No files matched your search

@@ -90,10 +90,13 @@ const EXPECTED_TYPES = [
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
"ANS_CHARGE",
"CARDIO_LOAD",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 63 canonical measurement types", () => {
it("exposes the 65 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -215,6 +218,13 @@ describe("measurementTypeEnum coverage", () => {
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
// v1.17.1 — Polar Nightly Recharge + Training Load Pro components. ANS
// charge and Cardio Load are device-derived recovery / strain composites,
// not measured clinical vitals; they surface on their own Insights cluster
// with a "descriptive, not clinical" framing and never belong in the
// clinical vitals PDF. See doctor-report-pdf-core.ts.
"ANS_CHARGE",
"CARDIO_LOAD",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+5
View File
@@ -115,6 +115,11 @@ export const ALLOWED_CHART_TOKENS = [
"metric:AVERAGE_HEART_RATE",
"metric:MAX_HEART_RATE",
"metric:SLEEP_DISTURBANCE_COUNT",
// v1.17.1 — Polar-native recovery / strain components. Continuous daily
// series, so each carries a `metric:<TYPE>` token and renders through the
// generic chart renderer alongside the WHOOP-native score classes.
"metric:ANS_CHARGE",
"metric:CARDIO_LOAD",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -60,6 +60,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
// v1.17.1 — Polar Nightly Recharge + Training Load Pro components. These
// ingest server-side from the Polar AccessLink API (source = POLAR), never
// from HealthKit: Apple ships no identifier for Polar's autonomic charge or
// its device-native cardio-load strain figure. No HK mapping by design.
"ANS_CHARGE",
"CARDIO_LOAD",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
+6
View File
@@ -188,6 +188,12 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["AVERAGE_HEART_RATE", "cardiovascular"],
["MAX_HEART_RATE", "cardiovascular"],
["SLEEP_DISTURBANCE_COUNT", "sleep"],
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
// ANS charge + Cardio Load are device-native recovery / strain composites,
// so they join the scores cluster alongside DAY_STRAIN.
["ANS_CHARGE", "scores"],
["CARDIO_LOAD", "scores"],
]);
/**
+7
View File
@@ -176,6 +176,13 @@ export function getPRDirection(
// WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction.
case "AVERAGE_HEART_RATE":
case "MAX_HEART_RATE":
// v1.17.1 — Polar-native recovery / strain components. ANS_CHARGE is a
// baseline-relative autonomic deviation (homeostatic, not a goal axis) and
// CARDIO_LOAD is a device-derived cardiovascular-load composite — neither a
// higher nor a lower value is an achievement, so they stay null like the
// other device-derived scores (HRV_RMSSD / DAY_STRAIN).
case "ANS_CHARGE":
case "CARDIO_LOAD":
return null;
}
}
+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", () => {
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
PolarApiError,
classifyPolarError,
isInvalidGrant,
} from "../response-classifier";
describe("classifyPolarError — invalid_grant reauth lift (H-1)", () => {
it("lifts a 400 invalid_grant on the token endpoint to reauth_required", () => {
const err = new PolarApiError({
verb: "exchangeCode",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_grant",
});
expect(isInvalidGrant(err)).toBe(true);
expect(classifyPolarError(err)).toBe("reauth_required");
});
it("recovers the verdict from the message shape after a lost prototype", () => {
// A pg-boss retry can strip the PolarApiError prototype; the message-shape
// fallback must still recognise the invalid_grant signal.
const bare = new Error("Polar exchangeCode error: 400 - invalid_grant");
expect(isInvalidGrant(bare)).toBe(true);
expect(classifyPolarError(bare)).toBe("reauth_required");
});
it("leaves a non-invalid_grant 400 as persistent", () => {
const err = new PolarApiError({
verb: "exchangeCode",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_client",
});
expect(isInvalidGrant(err)).toBe(false);
expect(classifyPolarError(err)).toBe("persistent");
});
it("keeps a 401 reauth and a 500 transient classification", () => {
expect(
classifyPolarError(
new PolarApiError({
verb: "fetchSleeps",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
}),
),
).toBe("reauth_required");
expect(
classifyPolarError(
new PolarApiError({
verb: "fetchSleeps",
classification: "transient",
httpStatus: 500,
reason: "http_500",
}),
),
).toBe("transient");
});
});
+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",
},
];
}
+13
View File
@@ -20,6 +20,7 @@ import {
classifyHttpStatus,
classifyIntegrationError,
IntegrationApiError,
isOAuthInvalidGrant,
type ClassifiedHttpResponse,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
@@ -48,6 +49,17 @@ export class PolarApiError extends IntegrationApiError {
}
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* a stale/replayed authorization code on Polar's only token call
* (`exchangeCode`). Lifts a 400 carrying `invalid_grant` from `persistent` to
* `reauth_required` so the connection surfaces the reconnect prompt instead of
* silently parking after 24 h.
*/
export function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Polar");
}
/**
* Read the classification from any caught error. A non-`PolarApiError` defaults
* to `transient` so a plain `Error` retries rather than disabling the
@@ -55,5 +67,6 @@ export class PolarApiError extends IntegrationApiError {
* caller that lost the prototype across a pg-boss retry.
*/
export function classifyPolarError(err: unknown): PolarClassification {
if (isInvalidGrant(err)) return "reauth_required";
return classifyIntegrationError(err, "Polar");
}
+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,
+17
View File
@@ -100,6 +100,13 @@ export const measurementTypeEnum = z.enum([
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
// v1.17.1 — Polar Nightly Recharge + Training Load Pro components ingested
// server-side as `source = POLAR`. ANS_CHARGE is the HRV-based autonomic
// charge (distinct from the 1–6 recovery band that maps to RECOVERY_SCORE);
// CARDIO_LOAD is Polar's device-native cardiovascular-strain figure (distinct
// from WHOOP DAY_STRAIN and the COMPUTED STRAIN_SCORE).
"ANS_CHARGE",
"CARDIO_LOAD",
]);
/**
@@ -312,6 +319,9 @@ const unitMap: Record<string, string> = {
MAX_HEART_RATE: "bpm",
// Per-night sleep disturbance tally — a plain integer count.
SLEEP_DISTURBANCE_COUNT: "count",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "score",
CARDIO_LOAD: "score",
};
export function getUnitForType(type: string): string {
@@ -487,6 +497,13 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a
// generous ceiling over any plausible severely-fragmented night.
SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 },
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
// ANS charge is a baseline-relative autonomic deviation that can run
// negative; a generous symmetric ±100 band covers any plausible reading.
// Cardio Load is a non-negative cumulative-strain figure; 1000 is a generous
// ceiling over any plausible single-day training load.
ANS_CHARGE: { min: -100, max: 100 },
CARDIO_LOAD: { min: 0, max: 1000 },
};
export function validateMeasurementRange(
+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,