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

+10
View File
@@ -1569,6 +1569,8 @@ paths:
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- in: query
name: from
schema:
@@ -1743,6 +1745,8 @@ paths:
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
value:
type: number
measuredAt:
@@ -4372,6 +4376,8 @@ paths:
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
required: true
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
@@ -8841,6 +8847,8 @@ components:
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
value:
type: number
unit:
@@ -9270,6 +9278,8 @@ components:
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
value:
type: number
unit:
+2
View File
@@ -550,6 +550,8 @@
"typeAverageHeartRate": "Durchschnittlicher Puls",
"typeMaxHeartRate": "Maximaler Puls",
"typeSleepDisturbanceCount": "Schlafstörungen",
"typeAnsCharge": "ANS-Ladung",
"typeCardioLoad": "Kardio-Belastung",
"filterBySource": "Nach Quelle filtern",
"sourceComputed": "Berechnet",
"sourceWhoop": "WHOOP",
+2
View File
@@ -550,6 +550,8 @@
"typeAverageHeartRate": "Average heart rate",
"typeMaxHeartRate": "Maximum heart rate",
"typeSleepDisturbanceCount": "Sleep disturbances",
"typeAnsCharge": "ANS charge",
"typeCardioLoad": "Cardio load",
"filterBySource": "Filter by source",
"sourceComputed": "Computed",
"sourceWhoop": "WHOOP",
+2
View File
@@ -550,6 +550,8 @@
"typeAverageHeartRate": "Frecuencia cardíaca media",
"typeMaxHeartRate": "Frecuencia cardíaca máxima",
"typeSleepDisturbanceCount": "Interrupciones del sueño",
"typeAnsCharge": "Carga del SNA",
"typeCardioLoad": "Carga cardio",
"filterBySource": "Filtrar por fuente",
"sourceComputed": "Calculado",
"sourceWhoop": "WHOOP",
+2
View File
@@ -550,6 +550,8 @@
"typeAverageHeartRate": "Fréquence cardiaque moyenne",
"typeMaxHeartRate": "Fréquence cardiaque maximale",
"typeSleepDisturbanceCount": "Perturbations du sommeil",
"typeAnsCharge": "Charge SNA",
"typeCardioLoad": "Charge cardio",
"filterBySource": "Filtrer par source",
"sourceComputed": "Calculé",
"sourceWhoop": "WHOOP",
+2
View File
@@ -550,6 +550,8 @@
"typeAverageHeartRate": "Frequenza cardiaca media",
"typeMaxHeartRate": "Frequenza cardiaca massima",
"typeSleepDisturbanceCount": "Disturbi del sonno",
"typeAnsCharge": "Carica SNA",
"typeCardioLoad": "Carico cardio",
"filterBySource": "Filtra per fonte",
"sourceComputed": "Calcolato",
"sourceWhoop": "WHOOP",
+2
View File
@@ -550,6 +550,8 @@
"typeAverageHeartRate": "Średnie tętno",
"typeMaxHeartRate": "Maksymalne tętno",
"typeSleepDisturbanceCount": "Zakłócenia snu",
"typeAnsCharge": "Ładunek ANS",
"typeCardioLoad": "Obciążenie kardio",
"filterBySource": "Filtruj według źródła",
"sourceComputed": "Obliczone",
"sourceWhoop": "WHOOP",
@@ -0,0 +1,20 @@
-- v1.17.1 — Polar pull-completeness: two native Polar signals get their own
-- MeasurementType so they stop being read off the wire and dropped at the mapper.
--
-- 1. `ANS_CHARGE` — Polar Nightly Recharge `ans_charge`, the HRV-based
-- autonomic-nervous-system charge. The primary differentiated recovery
-- component, distinct from the 1–6 recovery band already mapped to
-- RECOVERY_SCORE.
-- 2. `CARDIO_LOAD` — Polar Training Load Pro `cardio_load`, the device-native
-- cardiovascular-strain figure (Polar's WHOOP-day-strain analogue). Kept
-- distinct from DAY_STRAIN (WHOOP 0–21) and the COMPUTED STRAIN_SCORE so
-- each native value keeps its own bucket and scale.
--
-- Purely additive: two enum-extensions on `measurement_type`. No backfill, no
-- column change, no existing row touched. Idempotent (`IF NOT EXISTS`); reruns
-- are safe. Forward-only — Postgres cannot remove an enum value, and with no
-- rows carrying them before the app ships they are inert until the Polar sync
-- writes them.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ANS_CHARGE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_LOAD';
+11
View File
@@ -774,6 +774,17 @@ enum MeasurementType {
MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate.
SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances.
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro coverage (additive) ──
// Two native Polar signals with no existing home. ANS_CHARGE is the HRV-based
// autonomic-nervous-system charge component of Nightly Recharge — Polar's most
// differentiated recovery signal, distinct from the 1–6 recovery band that
// already maps to RECOVERY_SCORE. CARDIO_LOAD is Polar's Training Load Pro
// cardiovascular-strain figure (its WHOOP-day-strain analogue); kept distinct
// from DAY_STRAIN (WHOOP's 0–21 scale) and the COMPUTED STRAIN_SCORE proxy so
// each device-native value keeps its own bucket and native scale.
ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment.
CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket.
@@map("measurement_type")
}
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const {
stateMatchesCookieMock,
verifySignedStateMock,
getSessionMock,
exchangeCodeMock,
registerUserMock,
getCredsMock,
markReconnectedMock,
} = vi.hoisted(() => ({
stateMatchesCookieMock: vi.fn(),
verifySignedStateMock: vi.fn(),
getSessionMock: vi.fn(),
exchangeCodeMock: vi.fn(),
registerUserMock: vi.fn(),
getCredsMock: vi.fn(),
markReconnectedMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setAuth: vi.fn(), setError: vi.fn() }),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock }));
vi.mock("@/lib/polar/client", () => ({
exchangeCode: exchangeCodeMock,
registerUser: registerUserMock,
}));
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
oauthStateCookieName: () => "polar_oauth_state",
stateMatchesCookie: stateMatchesCookieMock,
verifySignedState: verifySignedStateMock,
}));
vi.mock("@/lib/integrations/status", () => ({
markReconnected: markReconnectedMock,
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function makeReq(): Parameters<typeof GET>[0] {
return {
url: "https://app.example/api/polar/callback?code=abc&state=s",
cookies: { get: () => ({ value: "s" }) },
} as unknown as Parameters<typeof GET>[0];
}
const run = GET as unknown as (req: ReturnType<typeof makeReq>) => Promise<{
status: number;
headers: { get(k: string): string | null };
}>;
beforeEach(() => {
vi.clearAllMocks();
stateMatchesCookieMock.mockReturnValue(true);
verifySignedStateMock.mockReturnValue({ userId: "u1" });
getSessionMock.mockResolvedValue(null);
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
exchangeCodeMock.mockResolvedValue({ access_token: "tok", x_user_id: 42 });
registerUserMock.mockResolvedValue(undefined);
});
describe("GET /api/polar/callback", () => {
it("completes the handshake and redirects to connected", async () => {
const res = await run(makeReq());
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("polar=connected");
expect(markReconnectedMock).toHaveBeenCalledWith("u1", "polar");
});
it("rejects a CSRF cookie/param mismatch before exchanging the code", async () => {
stateMatchesCookieMock.mockReturnValue(false);
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=csrf1");
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
it("rejects a cross-user session completing another user's handshake", async () => {
getSessionMock.mockResolvedValue({ user: { id: "other" } });
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=cross_user");
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
it("rejects an off-spec token body missing x_user_id", async () => {
exchangeCodeMock.mockResolvedValue({ access_token: "tok" });
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=token");
expect(registerUserMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted(
() => ({
rateLimitMock: vi.fn(),
getCredsMock: vi.fn(),
mintMock: vi.fn(() => "signed-state"),
authUrlMock: vi.fn(() => "https://flow.polar.com/oauth2/authorization?x=1"),
}),
);
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock }));
vi.mock("@/lib/polar/client", () => ({ getAuthorizationUrl: authUrlMock }));
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
OAUTH_STATE_TTL_MS: 600_000,
mintSignedState: mintMock,
oauthStateCookieName: () => "polar_oauth_state",
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
const run = GET as unknown as () => Promise<{
status: number;
headers: { get(k: string): string | null };
cookies: { get(k: string): { value: string } | undefined };
}>;
beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true });
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
describe("GET /api/polar/connect", () => {
it("redirects to the consent screen and sets the state cookie", async () => {
const res = await run();
expect(res.headers.get("location")).toContain("flow.polar.com");
expect(res.cookies.get("polar_oauth_state")?.value).toBe("signed-state");
});
it("redirects to an error when the bucket is exhausted", async () => {
rateLimitMock.mockResolvedValue({ allowed: false });
const res = await run();
expect(res.headers.get("location")).toContain("reason=rate_limited");
expect(getCredsMock).not.toHaveBeenCalled();
});
it("returns 400 when neither BYO nor env credentials resolve", async () => {
getCredsMock.mockResolvedValue(null);
const res = (await run()) as unknown as { status: number };
expect(res.status).toBe(400);
});
});
+6 -5
View File
@@ -15,11 +15,12 @@ import { NextResponse } from "next/server";
/**
* v1.17.0 (F4) — redirect the user to the Polar AccessLink consent screen.
*
* Full OAuth from env: the shared client id/secret come from `POLAR_CLIENT_ID`
* / `POLAR_CLIENT_SECRET` (not per-user BYO). The CSRF defence is a stateless
* signed state (`src/lib/oauth/signed-state.ts`): the same token is the `state`
* URL param AND an httpOnly cookie, and the callback enforces both signature
* validity and a byte-exact cookie match.
* BYO-key OAuth: the client id/secret resolve DB-first then env via
* `getPolarClientCredentials` — a user's own AccessLink app when stored,
* otherwise the shared `POLAR_CLIENT_ID` / `POLAR_CLIENT_SECRET`. The CSRF
* defence is a stateless signed state (`src/lib/oauth/signed-state.ts`): the
* same token is the `state` URL param AND an httpOnly cookie, and the callback
* enforces both signature validity and a byte-exact cookie match.
*
* Rate-limited per user so a session can't spam the consent redirect.
*/
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { storeMock, clearMock, auditMock, markDisconnectedMock } = vi.hoisted(
() => ({
storeMock: vi.fn(),
clearMock: vi.fn(),
auditMock: vi.fn(),
markDisconnectedMock: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: auditMock }));
vi.mock("@/lib/integrations/status", () => ({
markDisconnected: markDisconnectedMock,
}));
vi.mock("@/lib/polar/credentials", () => ({
storePolarClientCredentials: storeMock,
clearPolarClientCredentials: clearMock,
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: vi.fn(async (req: { _body: unknown }) => ({
data: req._body,
error: null,
})),
}));
import { PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
type RouteResult = { data: unknown; error: string | null; status: number };
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/polar/credentials", () => {
it("stores credentials and audit-logs the mutation", async () => {
const req = { _body: { clientId: "cid", clientSecret: "csecret" } };
const put = PUT as unknown as (r: typeof req) => Promise<RouteResult>;
const res = await put(req);
expect(res.status).toBe(200);
expect(storeMock).toHaveBeenCalledWith("u1", "cid", "csecret");
expect(auditMock).toHaveBeenCalledWith("polar.credentials.update", {
userId: "u1",
});
});
it("rejects an invalid body with a 422", async () => {
const req = { _body: { clientId: "" } };
const put = PUT as unknown as (r: typeof req) => Promise<RouteResult>;
const res = await put(req);
expect(res.status).toBe(422);
expect(storeMock).not.toHaveBeenCalled();
});
});
describe("DELETE /api/polar/credentials", () => {
it("clears creds + token and parks the ledger at disconnected", async () => {
const del = DELETE as unknown as () => Promise<RouteResult>;
const res = await del();
expect(res.status).toBe(200);
// The active token + member id are nulled.
expect(userUpdate).toHaveBeenCalledWith({
where: { id: "u1" },
data: { polarAccessTokenEncrypted: null, polarUserIdEncrypted: null },
});
expect(clearMock).toHaveBeenCalledWith("u1");
expect(auditMock).toHaveBeenCalledWith("polar.credentials.delete", {
userId: "u1",
});
expect(markDisconnectedMock).toHaveBeenCalledWith("u1", "polar");
});
});
+10
View File
@@ -1,7 +1,9 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
import {
storePolarClientCredentials,
clearPolarClientCredentials,
@@ -57,12 +59,18 @@ export const PUT = apiHandler(async (request: NextRequest) => {
result.data.clientId,
result.data.clientSecret,
);
await auditLog("polar.credentials.update", { userId: user.id });
return apiSuccess({ updated: true });
});
/**
* Delete Polar credentials and the active connection.
*
* Removing the BYO client id/secret also drops the live grant: a token minted
* under the user's own AccessLink app would be orphaned once the keys are gone.
* Parks the integration ledger at `disconnected` for parity with the dedicated
* disconnect route so the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
@@ -76,6 +84,8 @@ export const DELETE = apiHandler(async () => {
},
});
await clearPolarClientCredentials(user.id);
await auditLog("polar.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "polar");
return apiSuccess({ deleted: true });
});
@@ -114,6 +114,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate",
MAX_HEART_RATE: "measurements.typeMaxHeartRate",
SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "measurements.typeAnsCharge",
CARDIO_LOAD: "measurements.typeCardioLoad",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -225,6 +228,11 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
AVERAGE_HEART_RATE: HeartPulse,
MAX_HEART_RATE: Heart,
SLEEP_DISTURBANCE_COUNT: Waves,
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
// HeartPulse reads as the autonomic-charge signal; Gauge carries the
// cardio-load strain figure (same family as DAY_STRAIN).
ANS_CHARGE: HeartPulse,
CARDIO_LOAD: Gauge,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -326,4 +334,8 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
AVERAGE_HEART_RATE: "bg-chart-3/20 text-chart-3",
MAX_HEART_RATE: "bg-chart-3/20 text-chart-3",
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/20 text-chart-2",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
// chart-1 (strain/score family) for both, matching DAY_STRAIN.
ANS_CHARGE: "bg-chart-1/20 text-chart-1",
CARDIO_LOAD: "bg-chart-1/20 text-chart-1",
};
@@ -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,