diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index abfd1c6c3..1f183be88 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index a00c1da74..a92b68857 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 982cde664..d84442b10 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index d8a572cc4..47cf19601 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 52f5b9d53..b0ae9da61 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 7c50a8d29..3c3a8d1e1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index f96c44bb7..cfcbb80cb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0166_v1171_polar_recovery_strain/migration.sql b/prisma/migrations/0166_v1171_polar_recovery_strain/migration.sql new file mode 100644 index 000000000..96681435c --- /dev/null +++ b/prisma/migrations/0166_v1171_polar_recovery_strain/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c95adf984..7cb48fae8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts new file mode 100644 index 000000000..c290363ad --- /dev/null +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -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: 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[0] { + return { + url: "https://app.example/api/polar/callback?code=abc&state=s", + cookies: { get: () => ({ value: "s" }) }, + } as unknown as Parameters[0]; +} + +const run = GET as unknown as (req: ReturnType) => 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(); + }); +}); diff --git a/src/app/api/polar/connect/__tests__/route.test.ts b/src/app/api/polar/connect/__tests__/route.test.ts new file mode 100644 index 000000000..87511d54e --- /dev/null +++ b/src/app/api/polar/connect/__tests__/route.test.ts @@ -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: 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); + }); +}); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 65d94e708..f92c68f35 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -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. */ diff --git a/src/app/api/polar/credentials/__tests__/route.test.ts b/src/app/api/polar/credentials/__tests__/route.test.ts new file mode 100644 index 000000000..3b744bc78 --- /dev/null +++ b/src/app/api/polar/credentials/__tests__/route.test.ts @@ -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: 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; + +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; + 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; + 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; + 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"); + }); +}); diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts index 9a92d467d..7dbab091b 100644 --- a/src/app/api/polar/credentials/route.ts +++ b/src/app/api/polar/credentials/route.ts @@ -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 }); }); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index b171fd450..4fd724030 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -114,6 +114,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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 = { @@ -225,6 +228,11 @@ export const MEASUREMENT_TYPE_ICONS: Record = { 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 = { @@ -326,4 +334,8 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index dfeef8cac..39ca25522 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 5c1e104cf..11028f734 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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:` 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]; diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 2687f37b9..90203c390 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -60,6 +60,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "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", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index df0db5f29..5124c2646 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 91d2f82fa..00aae80c2 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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; } } diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 78dde0ff3..d68ae79f6 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -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", () => { diff --git a/src/lib/polar/__tests__/response-classifier.test.ts b/src/lib/polar/__tests__/response-classifier.test.ts new file mode 100644 index 000000000..6e9655872 --- /dev/null +++ b/src/lib/polar/__tests__/response-classifier.test.ts @@ -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"); + }); +}); diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index dda649bf6..2e0ecdd94 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -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([ diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 322b0a160..1de24b073 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -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 { @@ -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 { + return fetchCollection( + `/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 { + return fetchCollection( + `/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 = :`. */ + * stamps `source = POLAR` + `externalId = ::` 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 `::`. 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, 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", + }, + ]; +} diff --git a/src/lib/polar/response-classifier.ts b/src/lib/polar/response-classifier.ts index 30297e60b..41eec2a8c 100644 --- a/src/lib/polar/response-classifier.ts +++ b/src/lib/polar/response-classifier.ts @@ -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"); } diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 38ec71e69..1820792f4 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -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 // `::` — 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 { 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 { 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, diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index a10432006..011d85787 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { 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 = { // 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( diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index a6997a775..f613f488f 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -261,8 +261,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { 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,