mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
merge(polar): pull ANS charge, cardio load, SpO2, distance; fix sleep timeline; invalid_grant + reconcile
This commit is contained in:
commit
412b22e68e
28 files changed
+936
-32
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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