From 7e54b6be817a72a81cc1cea7823762d5c01dd4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:30:10 +0200 Subject: [PATCH] refactor(integrations): split the provider clients into wire types, endpoints and mappers Oura, Polar, WHOOP and Strava each keep the transport and endpoints in `client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the pure record-to-row mappers, no I/O). Google Health's 1,600-line client becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error detail, retry, rate limits), `data-points.ts` (the paginated walker) and a small `client.ts` for the profile and roll-up reads; its `export * from "./mappers"` facade is gone and its importers name the module that defines what they use. The code moved verbatim; the mapper goldens written before the move and the provider integration tests pass unchanged. --- .../baselines/source-file-lines.json | 7 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 6 +- .../integrations/google-health/test/route.ts | 8 +- .../__tests__/backfill-bounds.test.ts | 6 +- .../backfill-resume-progress.test.ts | 2 +- .../__tests__/date-filter-fallback.test.ts | 3 +- .../__tests__/edge-filter-fallback.test.ts | 12 +- .../__tests__/map-fixtures.test.ts | 8 +- .../google-health/__tests__/mappers.test.ts | 8 +- .../__tests__/rate-limit-and-token.test.ts | 6 +- .../__tests__/rollup-request.test.ts | 6 +- .../__tests__/sync-activity-failsoft.test.ts | 10 +- .../sync-metrics-dense-pages.test.ts | 6 +- .../__tests__/sync-metrics-failsoft.test.ts | 9 +- .../sync-sleep-morning-refresh.test.ts | 6 +- .../__tests__/sync-time-budget.test.ts | 2 +- .../sync-workout-insert-identity.test.ts | 6 +- .../__tests__/walk-resume-and-retry.test.ts | 8 +- src/lib/google-health/budget.ts | 278 ++++ src/lib/google-health/client.ts | 1232 +---------------- src/lib/google-health/data-points.ts | 565 ++++++++ src/lib/google-health/request.ts | 390 ++++++ src/lib/google-health/sync-activity.ts | 8 +- src/lib/google-health/sync-core.ts | 6 +- src/lib/google-health/sync-metrics.ts | 12 +- src/lib/google-health/sync-sleep.ts | 5 +- src/lib/google-health/sync-workout.ts | 6 +- src/lib/google-health/sync.ts | 2 +- .../__tests__/sleep-score-oura-rhythm.test.ts | 3 +- .../__tests__/google-health-backfill.test.ts | 12 +- src/lib/jobs/google-health-backfill.ts | 2 +- src/lib/oura/__tests__/client.test.ts | 10 +- src/lib/oura/__tests__/map-fixtures.test.ts | 4 +- .../__tests__/workouts-heart-rate.test.ts | 2 +- src/lib/oura/client.ts | 760 +--------- src/lib/oura/cycle-sync.ts | 6 +- src/lib/oura/map.ts | 556 ++++++++ src/lib/oura/sync-heart-rate.ts | 9 +- src/lib/oura/sync-workouts.ts | 2 +- src/lib/oura/sync.ts | 10 +- src/lib/oura/wire-types.ts | 201 +++ .../polar/__tests__/client-exercises.test.ts | 6 +- src/lib/polar/__tests__/client.test.ts | 8 +- src/lib/polar/__tests__/map-fixtures.test.ts | 4 +- src/lib/polar/__tests__/parity.test.ts | 7 +- src/lib/polar/__tests__/sync-workouts.test.ts | 2 +- src/lib/polar/client.ts | 640 +-------- src/lib/polar/map.ts | 536 +++++++ src/lib/polar/sync-workouts.ts | 3 +- src/lib/polar/sync.ts | 4 +- src/lib/polar/wire-types.ts | 103 ++ src/lib/strava/__tests__/client.test.ts | 5 +- src/lib/strava/__tests__/map-fixtures.test.ts | 7 +- src/lib/strava/client.ts | 194 +-- src/lib/strava/map.ts | 152 ++ src/lib/strava/sync.ts | 11 +- src/lib/strava/wire-types.ts | 37 + src/lib/whoop/__tests__/client.test.ts | 10 +- src/lib/whoop/__tests__/map-fixtures.test.ts | 7 +- src/lib/whoop/__tests__/sync-body.test.ts | 2 +- .../whoop/__tests__/sync-sleep-sweep.test.ts | 2 +- src/lib/whoop/__tests__/sync-workout.test.ts | 2 +- .../__tests__/webhook-notification.test.ts | 5 +- src/lib/whoop/client.ts | 582 +------- src/lib/whoop/map.ts | 441 ++++++ src/lib/whoop/sync-body.ts | 3 +- src/lib/whoop/sync-cycle.ts | 3 +- src/lib/whoop/sync-recovery.ts | 2 +- src/lib/whoop/sync-sleep.ts | 3 +- src/lib/whoop/sync-workout.ts | 9 +- src/lib/whoop/webhook-handler.ts | 2 +- src/lib/whoop/wire-types.ts | 124 ++ .../google-health-backfill-multi-year.test.ts | 4 +- .../google-health-backfill-rate-limit.test.ts | 2 +- ...oogle-health-dense-backfill-memory.test.ts | 5 +- .../google-health-workout-terminal.test.ts | 7 +- tests/integration/strava-sync.test.ts | 2 +- 78 files changed, 3651 insertions(+), 3489 deletions(-) create mode 100644 src/lib/google-health/budget.ts create mode 100644 src/lib/google-health/data-points.ts create mode 100644 src/lib/google-health/request.ts create mode 100644 src/lib/oura/map.ts create mode 100644 src/lib/oura/wire-types.ts create mode 100644 src/lib/polar/map.ts create mode 100644 src/lib/polar/wire-types.ts create mode 100644 src/lib/strava/map.ts create mode 100644 src/lib/strava/wire-types.ts create mode 100644 src/lib/whoop/map.ts create mode 100644 src/lib/whoop/wire-types.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 728d41357..f71967a10 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 116, - "total": 136568, + "count": 113, + "total": 132997, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -73,7 +73,6 @@ "lib/fitbit/sync-core.ts": 951, "lib/gamification/achievements-result.ts": 1094, "lib/gamification/achievements.ts": 913, - "lib/google-health/client.ts": 1589, "lib/google-health/mappers.ts": 1295, "lib/google-health/sync-core.ts": 1621, "lib/idempotency.ts": 879, @@ -107,9 +106,7 @@ "lib/medications/scheduling/recurrence.ts": 1108, "lib/medications/scheduling/slot-upsert.ts": 926, "lib/notifications/senders/apns.ts": 1100, - "lib/oura/client.ts": 1089, "lib/oura/sync.ts": 900, - "lib/polar/client.ts": 893, "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 1046, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d7f5955f3..0144c7916 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -326,8 +326,8 @@ "lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363, "lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 356, "lib/gamification/achievements-result.ts :: buildAchievementsResult": 586, - "lib/google-health/client.ts :: forEachDataPointPage": 288, - "lib/google-health/client.ts :: forEachDataPointPage > walk": 223, + "lib/google-health/data-points.ts :: forEachDataPointPage": 288, + "lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223, "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453, "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, "lib/import/labs-csv.ts :: importLabsCsv": 309, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index c65b301db..ad663138c 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2921, - "total": 10169, + "count": 2919, + "total": 10165, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2079,7 +2079,6 @@ "lib/google-health/__tests__/sync-intraday-daily-wide.test.ts": 1, "lib/google-health/__tests__/sync-intraday-overlap.test.ts": 1, "lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts": 1, - "lib/google-health/client.ts": 1, "lib/google-health/sync-core.ts": 2, "lib/google-health/sync-metrics.ts": 1, "lib/i18n/__tests__/relative-time-updated.test.ts": 2, @@ -2899,7 +2898,6 @@ "lib/whoop/__tests__/sync-workout-overlap.test.ts": 1, "lib/whoop/__tests__/sync.test.ts": 1, "lib/whoop/__tests__/webhook-notification.test.ts": 1, - "lib/whoop/client.ts": 3, "lib/whoop/connect-ticket.ts": 1, "lib/whoop/oauth-state.ts": 1, "lib/whoop/return-scheme.ts": 1, diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts index 79bc9694b..1641dd0d0 100644 --- a/src/app/api/integrations/google-health/test/route.ts +++ b/src/app/api/integrations/google-health/test/route.ts @@ -5,13 +5,13 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { getValidToken } from "@/lib/google-health/sync-core"; +import { fetchDailyRollUp } from "@/lib/google-health/client"; import { GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, - GOOGLE_HEALTH_API_BASE, - GOOGLE_HEALTH_DATA_TYPES, - fetchDailyRollUp, fetchDataPoints, -} from "@/lib/google-health/client"; +} from "@/lib/google-health/data-points"; +import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/request"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "@/lib/google-health/response-classifier"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; diff --git a/src/lib/google-health/__tests__/backfill-bounds.test.ts b/src/lib/google-health/__tests__/backfill-bounds.test.ts index 7bc28ad46..fb22d2f79 100644 --- a/src/lib/google-health/__tests__/backfill-bounds.test.ts +++ b/src/lib/google-health/__tests__/backfill-bounds.test.ts @@ -43,12 +43,12 @@ import { googleHealthBackfillRetryPending, readGoogleHealthBackfillProgress, } from "../backfill-progress"; +import { forEachDataPointPage } from "@/lib/google-health/data-points"; import { - forEachDataPointPage, - GOOGLE_HEALTH_DATA_TYPES, noteGoogleHealthDeferred, runWithGoogleHealthClientOutcome, -} from "../client"; +} from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; const MINUTE = 60_000; const T0 = Date.parse("2026-10-17T12:00:00Z"); diff --git a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts index 80b2b2486..328d33d39 100644 --- a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts +++ b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts @@ -32,7 +32,7 @@ import { googleHealthSliceBudget, runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, -} from "../client"; +} from "@/lib/google-health/budget"; beforeEach(() => { vi.clearAllMocks(); diff --git a/src/lib/google-health/__tests__/date-filter-fallback.test.ts b/src/lib/google-health/__tests__/date-filter-fallback.test.ts index 981468d66..4bf56d3dc 100644 --- a/src/lib/google-health/__tests__/date-filter-fallback.test.ts +++ b/src/lib/google-health/__tests__/date-filter-fallback.test.ts @@ -18,7 +18,8 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { return { ...actual, safeFetch: safeFetchMock }; }); -import { GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints } from "../client"; +import { fetchDataPoints } from "@/lib/google-health/data-points"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { WideEventBuilder } from "@/lib/logging/event-builder"; import { eventStorage } from "@/lib/logging/context"; import { GoogleHealthApiError } from "../response-classifier"; diff --git a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts index 1c827912a..f93fca69e 100644 --- a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts +++ b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts @@ -32,15 +32,19 @@ vi.mock("@/lib/logging/context", () => ({ })); import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_SKIP_MS, - type GoogleHealthDataPoint, forEachDataPointPage, - googleHealthSliceBudget, refusesEdgeFilter, +} from "@/lib/google-health/data-points"; +import { + googleHealthSliceBudget, runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, -} from "../client"; +} from "@/lib/google-health/budget"; +import { + GOOGLE_HEALTH_DATA_TYPES, + type GoogleHealthDataPoint, +} from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "../response-classifier"; import { GOOGLE_HEALTH_NO_PROGRESS_PAGES } from "../backfill-bounds"; import { diff --git a/src/lib/google-health/__tests__/map-fixtures.test.ts b/src/lib/google-health/__tests__/map-fixtures.test.ts index 4ddd7320f..289a099fc 100644 --- a/src/lib/google-health/__tests__/map-fixtures.test.ts +++ b/src/lib/google-health/__tests__/map-fixtures.test.ts @@ -51,13 +51,15 @@ import { buildDailyRollUpBody, chunkCivilRange, civilDateInTz, + resolveGoogleHealthUserId, +} from "../client"; +import { refusesEdgeFilter } from "@/lib/google-health/data-points"; +import { extractGoogleApiErrorDetail, formatCivilBound, googleHealthRetryDelayMs, incrementalFilter, - refusesEdgeFilter, - resolveGoogleHealthUserId, -} from "../client"; +} from "@/lib/google-health/request"; import dailySummariesJson from "./__fixtures__/daily-summaries.json"; import exerciseJson from "./__fixtures__/exercise.json"; import rollupsJson from "./__fixtures__/rollups.json"; diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 36be47ff2..1d805d99d 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -14,10 +14,13 @@ */ import { describe, expect, it } from "vitest"; +import { chunkCivilRange } from "../client"; +import { mapVo2Max } from "@/lib/google-health/mappers"; import { - chunkCivilRange, formatCivilBound, incrementalFilter, +} from "@/lib/google-health/request"; +import { GOOGLE_HEALTH_DATA_TYPES, mapActiveEnergy, mapBloodGlucose, @@ -36,11 +39,10 @@ import { mapSleepSession, mapSleepSessionDetailed, mapSteps, - mapVo2Max, mapWeight, mapWorkout, mapWristTemperature, -} from "../client"; +} from "@/lib/google-health/mappers"; describe("mapGoogleHealthSportType", () => { it("keeps Badminton as a first-class workout sport", () => { diff --git a/src/lib/google-health/__tests__/rate-limit-and-token.test.ts b/src/lib/google-health/__tests__/rate-limit-and-token.test.ts index 4a80869fb..8a1c04868 100644 --- a/src/lib/google-health/__tests__/rate-limit-and-token.test.ts +++ b/src/lib/google-health/__tests__/rate-limit-and-token.test.ts @@ -21,12 +21,12 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { return { ...actual, safeFetch: safeFetchMock }; }); +import { fetchDataPoints } from "@/lib/google-health/data-points"; import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_RATE_LIMIT, - fetchDataPoints, googleHealthRetryDelayMs, -} from "../client"; +} from "@/lib/google-health/request"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { collapseNaturalKeyTwins } from "../sync-core"; import { GoogleHealthApiError } from "../response-classifier"; diff --git a/src/lib/google-health/__tests__/rollup-request.test.ts b/src/lib/google-health/__tests__/rollup-request.test.ts index 770010be4..99eda4c04 100644 --- a/src/lib/google-health/__tests__/rollup-request.test.ts +++ b/src/lib/google-health/__tests__/rollup-request.test.ts @@ -20,15 +20,15 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { }); import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS, GOOGLE_HEALTH_ROLLUP_RANGE_DAYS, buildDailyRollUpBody, - extractGoogleApiErrorDetail, fetchDailyRollUp, - runWithGoogleHealthClientOutcome, type GoogleHealthRollupShape, } from "../client"; +import { extractGoogleApiErrorDetail } from "@/lib/google-health/request"; +import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "../response-classifier"; function jsonResponse(status: number, body: unknown): Response { diff --git a/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts b/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts index 7c08c9bcd..ab1570d9a 100644 --- a/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts @@ -16,11 +16,11 @@ const { fetchDailyRollUpMock, fetchDataPointsMock, handleErrorMock } = vi.mock("../client", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - fetchDailyRollUp: fetchDailyRollUpMock, - fetchDataPoints: fetchDataPointsMock, - }; + return { ...actual, fetchDailyRollUp: fetchDailyRollUpMock }; +}); +vi.mock("../data-points", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchDataPoints: fetchDataPointsMock }; }); vi.mock("../sync-core", () => ({ getValidToken: vi.fn(async () => ({ diff --git a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts index becd43db9..4410552c2 100644 --- a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts @@ -46,10 +46,8 @@ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn(async () => "UTC"), })); -import { - GOOGLE_HEALTH_PAGE_SIZE, - runWithGoogleHealthClientOutcome, -} from "../client"; +import { GOOGLE_HEALTH_PAGE_SIZE } from "@/lib/google-health/data-points"; +import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget"; import { syncUserMetrics } from "../sync-metrics"; const PAGES = 3; diff --git a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts index 937523c14..d0a9d1227 100644 --- a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts @@ -22,8 +22,8 @@ const { fetchDataPointsMock, handleErrorMock, mapWeightMock } = vi.hoisted( }), ); -vi.mock("../client", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../data-points", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchDataPoints: fetchDataPointsMock, @@ -44,9 +44,12 @@ vi.mock("../client", async (importOriginal) => { if (points.length > 0) await onPage(points); }, ), - mapWeight: mapWeightMock, }; }); +vi.mock("../mappers", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, mapWeight: mapWeightMock }; +}); vi.mock("../sync-core", () => ({ getValidToken: vi.fn(async () => ({ diff --git a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts index 4ca2f771b..eb647f726 100644 --- a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts +++ b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts @@ -26,10 +26,12 @@ const { maybeEnqueueMorningRefreshMock: vi.fn(async () => {}), })); -vi.mock("../client", () => ({ +vi.mock("../data-points", () => ({ GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100, - GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" }, fetchDataPoints: fetchDataPointsMock, +})); +vi.mock("../mappers", () => ({ + GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" }, mapSleepSessionDetailed: mapSleepSessionDetailedMock, })); diff --git a/src/lib/google-health/__tests__/sync-time-budget.test.ts b/src/lib/google-health/__tests__/sync-time-budget.test.ts index 0ed7cebb0..0925858b9 100644 --- a/src/lib/google-health/__tests__/sync-time-budget.test.ts +++ b/src/lib/google-health/__tests__/sync-time-budget.test.ts @@ -54,7 +54,7 @@ vi.mock("../sync-sleep", () => ({ syncUserSleep: resources.sleep })); vi.mock("../sync-activity", () => ({ syncUserActivity: resources.activity })); vi.mock("../sync-metrics", () => ({ syncUserMetrics: resources.metrics })); -import { googleHealthSliceExhausted } from "../client"; +import { googleHealthSliceExhausted } from "@/lib/google-health/budget"; import { syncUserGoogleHealth } from "../sync"; beforeEach(() => { diff --git a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts index b92e7fb97..5440c037a 100644 --- a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts +++ b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts @@ -29,10 +29,12 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn(async () => "UTC"), })); -vi.mock("../client", () => ({ +vi.mock("../data-points", () => ({ GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100, - GOOGLE_HEALTH_DATA_TYPES: { exercise: "exercise" }, fetchDataPoints, +})); +vi.mock("../mappers", () => ({ + GOOGLE_HEALTH_DATA_TYPES: { exercise: "exercise" }, mapWorkout, })); vi.mock("../sync-core", () => ({ diff --git a/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts index 4054883b7..b8f3dffe4 100644 --- a/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts +++ b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts @@ -22,14 +22,14 @@ vi.mock("@/lib/logging/context", () => ({ })); import { SafeFetchError } from "@/lib/safe-fetch"; +import { forEachDataPointPage } from "@/lib/google-health/data-points"; +import { GOOGLE_HEALTH_TRANSIENT_RETRY } from "@/lib/google-health/request"; import { - GOOGLE_HEALTH_DATA_TYPES, - GOOGLE_HEALTH_TRANSIENT_RETRY, - forEachDataPointPage, googleHealthSliceBudget, runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, -} from "../client"; +} from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "../response-classifier"; function page(n: number, next: boolean, status = 200, headers = {}): Response { diff --git a/src/lib/google-health/budget.ts b/src/lib/google-health/budget.ts new file mode 100644 index 000000000..8fb703ac0 --- /dev/null +++ b/src/lib/google-health/budget.ts @@ -0,0 +1,278 @@ +/** + * The limits a Google Health walk runs under and what it reports back. + * + * A walk asks before each request whether to stop: on the job's own budget + * (the full-history backfill runs under its pg-boss job's budget and abort + * signal), and, in a backfill slice, on the slice's tighter limit of minutes + * and pages. Each walk also adds to the per-resource outcome tally the cycle + * reports (pages, fetched, mapped, written, truncated, reason). Both live in + * async-local scopes the sync opens, so the walker needs no parameters for + * them. + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +export interface GoogleHealthClientOutcome { + pages: number; + fetched: number; + mapped: number; + written: number; + truncated: boolean; + reasonCode: + | "collection_failed" + | "token_failed" + | "upsert_failed" + | "rollup_failed" + | "existing_page_limit" + | null; +} + +const clientOutcomeStorage = new AsyncLocalStorage(); + +function addOutcomeCount(current: number, value: number): number { + if (!Number.isFinite(value) || value <= 0) return current; + return Math.min(2_147_483_647, current + Math.trunc(value)); +} + +export function notePagination( + pages: number, + fetched: number, + truncated: boolean, +): void { + const tracker = clientOutcomeStorage.getStore(); + if (!tracker) return; + tracker.pages = addOutcomeCount(tracker.pages, pages); + tracker.fetched = addOutcomeCount(tracker.fetched, fetched); + if (truncated) { + tracker.truncated = true; + tracker.reasonCode = "existing_page_limit"; + } +} + +export function noteGoogleHealthMapped(count: number): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker) tracker.mapped = addOutcomeCount(tracker.mapped, count); +} + +export function noteGoogleHealthWritten(count: number): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker) tracker.written = addOutcomeCount(tracker.written, count); +} + +export function noteGoogleHealthOutcomeFailure( + reasonCode: Exclude, +): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker && !tracker.reasonCode) tracker.reasonCode = reasonCode; +} + +/** Whether a walk in the current resource scope has ended short so far. */ +export function googleHealthOutcomeTruncated(): boolean { + return clientOutcomeStorage.getStore()?.truncated === true; +} + +export async function runWithGoogleHealthClientOutcome( + fn: () => Promise, +): Promise<{ result: T; outcome: GoogleHealthClientOutcome }> { + const outcome: GoogleHealthClientOutcome = { + pages: 0, + fetched: 0, + mapped: 0, + written: 0, + truncated: false, + reasonCode: null, + }; + const result = await clientOutcomeStorage.run(outcome, fn); + return { result, outcome }; +} + +/** + * A stop condition for the walks below, set by a caller that runs under a + * time limit (the full-history backfill, which runs under its pg-boss job's + * budget and abort signal). A walk asks it before each request and, when it + * says stop, ends there and reports itself truncated, the same outcome as + * reaching the page ceiling: the resource is incomplete, the cycle is not + * stamped, and the reason shown is that only part of the history was + * processed. Outside such a scope the walks never stop early. + * + * A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`): + * minutes and pages rather than the job's hours. Only a walk that keeps its + * place across attempts (`resumable`) honours it, because only such a walk + * loses nothing by stopping; a walk that would start over from its first page + * runs on to the end under the job's limit alone. + */ +interface GoogleHealthWalkLimits { + shouldStop: () => boolean; + slice?: GoogleHealthSliceBudget; +} + +/** Why a backfill slice stopped walking. */ +export type GoogleHealthSliceStopReason = + "time_budget" | "page_budget" | "rate_limited" | "job_budget" | "lane_wanted"; + +/** + * One backfill slice's share of the work: a span of wall-clock time and a + * page count, both well inside the job's own expiry. The clock starts the + * first time a walk asks, which is after the slice has the account's lock: a + * slice that waited for an hourly sync to finish does not start out with its + * time already spent. `pages` counts every page the slice's walks have read; + * `stopReason` is set the first time a limit is reached and stays set, so + * every walk after it stops too. + */ +export interface GoogleHealthSliceBudget { + ms: number; + /** When the slice's time is up; null until the first walk asks. */ + deadline: number | null; + maxPages: number; + pages: number; + /** + * Of `pages`, those a walk only passed over on its way to its edge, because + * Google refused the edge filter (see `forEachDataPointPage`). They are not + * charged against `maxPages`. Their time is: a skip runs on the slice's + * clock like any other read, and on its own shorter limit + * (`GOOGLE_HEALTH_SKIP_MS`), and keeps its place for the next slice when it + * stops. Only a skip that had to start over from the newest page, because + * its saved place was refused, moves `deadline` on by the time it took. + */ + skippedPages: number; + stopReason: GoogleHealthSliceStopReason | null; + now: () => number; + /** + * Whether another heavy job waits for the lane this slice holds + * (`laneWanted` in `jobs/heavy-lane.ts`). Asked where the slice's own + * limits are, so giving way keeps every walk's place, and only once the + * slice has read a page, so the slice it ends has something on disk and is + * continued rather than failed. + */ + yieldWanted: () => boolean; +} + +/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */ +export function googleHealthSliceBudget( + ms: number, + maxPages: number, + now: () => number = Date.now, + yieldWanted: () => boolean = () => false, +): GoogleHealthSliceBudget { + return { + ms, + deadline: null, + maxPages, + pages: 0, + skippedPages: 0, + stopReason: null, + now, + yieldWanted, + }; +} + +const stopStorage = new AsyncLocalStorage(); + +/** Whether the slice in scope has used its budget (and why, once it has). */ +function sliceExhausted(slice: GoogleHealthSliceBudget): boolean { + if (slice.stopReason) return true; + slice.deadline ??= slice.now() + slice.ms; + if (slice.now() >= slice.deadline) slice.stopReason = "time_budget"; + else if (slice.pages - slice.skippedPages >= slice.maxPages) { + slice.stopReason = "page_budget"; + } else if (slice.pages > 0 && slice.yieldWanted()) { + slice.stopReason = "lane_wanted"; + } + return slice.stopReason !== null; +} + +/** + * Whether a walk should stop before its next request. Every walk stops on the + * job's limit; a resumable one also stops on the slice's. + */ +export function stopRequested(resumable = false): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return ( + resumable && limits.slice !== undefined && sliceExhausted(limits.slice) + ); +} + +/** + * Whether the backfill slice in scope has used its budget. A collection that + * has not started yet waits for the next slice rather than starting now. + * False outside a slice. + */ +export function googleHealthSliceExhausted(): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return limits.slice !== undefined && sliceExhausted(limits.slice); +} + +/** Pages the slice in scope has read so far (0 outside a slice). */ +export function googleHealthSlicePagesRead(): number { + return stopStorage.getStore()?.slice?.pages ?? 0; +} + +/** Count one page read against the slice in scope, if any. */ +export function noteSlicePage(): void { + const slice = stopStorage.getStore()?.slice; + if (slice) slice.pages += 1; +} + +/** + * Note one page the walk only passed over on its way to its edge: read, but + * not charged against the slice's pages (no-op outside a slice). + */ +export function noteSliceSkip(): void { + const slice = stopStorage.getStore()?.slice; + if (slice) slice.skippedPages += 1; +} + +/** + * Give back to the slice in scope the time a skip took that had to start over + * from the newest page (no-op outside a slice). + */ +export function extendSliceDeadline(ms: number): void { + const slice = stopStorage.getStore()?.slice; + if (slice && slice.deadline !== null) slice.deadline += ms; +} + +/** The slice clock in scope, or the wall clock outside a slice. */ +export function sliceNow(): number { + return stopStorage.getStore()?.slice?.now() ?? Date.now(); +} + +/** End the slice in scope for `reason` (no-op outside a slice). */ +export function endSlice(reason: GoogleHealthSliceStopReason): void { + const slice = stopStorage.getStore()?.slice; + if (slice && !slice.stopReason) slice.stopReason = reason; +} + +/** Whether a resumable walk can stop and keep its place here. */ +export function inSlice(): boolean { + return stopStorage.getStore()?.slice !== undefined; +} + +export async function runWithGoogleHealthStop( + shouldStop: () => boolean, + fn: () => Promise, + slice?: GoogleHealthSliceBudget, +): Promise { + return stopStorage.run({ shouldStop, slice }, fn); +} + +/** + * Mark the resource in scope truncated without a walk: a collection a slice + * left for the next one. The cycle then reports itself incomplete, so the + * backfill does not mistake a skipped collection for an empty one. + */ +export function noteGoogleHealthDeferred(): void { + notePagination(0, 0, true); +} diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index b731f9597..0228a7a53 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -1,329 +1,37 @@ /** - * Google Health API client — OAuth + data-point reads (v1.27.0). + * Google Health API endpoints: the profile read that gives a connection its + * external user id, and the daily roll-up reads. * Docs: https://developers.google.com/health (health.googleapis.com/v4, the - * successor to the Fitbit Web API, unifying Fitbit + Pixel Watch + Fitbit Air). + * successor to the Fitbit Web API, unifying Fitbit, Pixel Watch and Fitbit + * Air). * - * Hand-rolled fetch over `safeFetch` (no SDK): the OAuth handshake - * (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), a single - * profile fetch for the connection's external user id, and the paginated - * `dataPoints.list` walker + per-type mappers. - * - * KEY OAUTH SEMANTICS (verified 2026 contract): - * - Access-token TTL = 1 h (contrast the classic Fitbit Web API's 8 h). - * - Refresh tokens do NOT rotate — a routine refresh returns a fresh - * `access_token` WITHOUT a `refresh_token`; the sync layer keeps the stored - * one (see `getValidToken` in `sync.ts`). Refresh tokens are time-based: - * they expire after 6 months of disuse, or — in a consent screen still in - * "Testing" publishing mode — after 7 DAYS, at which point the user must - * re-consent. A revoked / expired refresh token surfaces `invalid_grant` - * (or a 401) on the token endpoint; `postToken` lifts that onto the - * `reauth_required` class so the connection prompts a reconnect rather than - * a generic hard error. - * - PKCE (S256): the authorize request carries a `code_challenge`; the - * callback presents the matching `code_verifier` on exchange. Google's - * web-server (confidential) client also sends the client secret via HTTP - * Basic auth (RFC 6749 §2.3.1) — Basic + PKCE together. - * - `access_type=offline` + `prompt=consent` are required to reliably receive - * a refresh token (and force one on every re-consent). - * - * The pure mapping layer (payload → Measurement / Workout shapes) lives in - * `mappers.ts` and is re-exported below, so the sync layer and tests keep a - * single `./client` import surface. The shared wire-shape symbols - * (`GoogleHealthDataType`, `GOOGLE_HEALTH_DATA_TYPES`, the point types) live in - * `mappers.ts` and are imported here type-only — that direction keeps the - * module graph acyclic (`client` → `mappers`, never back). + * The integration is split by concern: the OAuth handshake is `./oauth.ts`, + * the request layer (error detail, retry, rate limits) `./request.ts`, the + * paginated `dataPoints.list` walker `./data-points.ts`, the backfill slice + * budget and the per-cycle outcome tally `./budget.ts`, and the pure payload + * mapping `./mappers.ts`. */ -import { AsyncLocalStorage } from "node:async_hooks"; import { annotate, getEvent } from "@/lib/logging/context"; -import { SafeFetchError, safeFetch } from "@/lib/safe-fetch"; +import { safeFetch } from "@/lib/safe-fetch"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { GoogleHealthApiError, classifyGoogleHealthResponse, } from "./response-classifier"; import { - googleHealthSampleInstant, - type GoogleHealthDataPoint, type GoogleHealthDataType, type GoogleHealthRollupPoint, } from "./mappers"; -import { - GOOGLE_HEALTH_NO_PROGRESS_PAGES, - googleHealthWalkProgress, - noteGoogleHealthWalkPage, -} from "./backfill-bounds"; + import { walkPages } from "@/lib/integrations/page-walk"; -import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; -export * from "./mappers"; - -export interface GoogleHealthClientOutcome { - pages: number; - fetched: number; - mapped: number; - written: number; - truncated: boolean; - reasonCode: - | "collection_failed" - | "token_failed" - | "upsert_failed" - | "rollup_failed" - | "existing_page_limit" - | null; -} - -const clientOutcomeStorage = new AsyncLocalStorage(); - -function addOutcomeCount(current: number, value: number): number { - if (!Number.isFinite(value) || value <= 0) return current; - return Math.min(2_147_483_647, current + Math.trunc(value)); -} - -function notePagination( - pages: number, - fetched: number, - truncated: boolean, -): void { - const tracker = clientOutcomeStorage.getStore(); - if (!tracker) return; - tracker.pages = addOutcomeCount(tracker.pages, pages); - tracker.fetched = addOutcomeCount(tracker.fetched, fetched); - if (truncated) { - tracker.truncated = true; - tracker.reasonCode = "existing_page_limit"; - } -} - -export function noteGoogleHealthMapped(count: number): void { - const tracker = clientOutcomeStorage.getStore(); - if (tracker) tracker.mapped = addOutcomeCount(tracker.mapped, count); -} - -export function noteGoogleHealthWritten(count: number): void { - const tracker = clientOutcomeStorage.getStore(); - if (tracker) tracker.written = addOutcomeCount(tracker.written, count); -} - -export function noteGoogleHealthOutcomeFailure( - reasonCode: Exclude, -): void { - const tracker = clientOutcomeStorage.getStore(); - if (tracker && !tracker.reasonCode) tracker.reasonCode = reasonCode; -} - -/** Whether a walk in the current resource scope has ended short so far. */ -export function googleHealthOutcomeTruncated(): boolean { - return clientOutcomeStorage.getStore()?.truncated === true; -} - -export async function runWithGoogleHealthClientOutcome( - fn: () => Promise, -): Promise<{ result: T; outcome: GoogleHealthClientOutcome }> { - const outcome: GoogleHealthClientOutcome = { - pages: 0, - fetched: 0, - mapped: 0, - written: 0, - truncated: false, - reasonCode: null, - }; - const result = await clientOutcomeStorage.run(outcome, fn); - return { result, outcome }; -} - -/** - * A stop condition for the walks below, set by a caller that runs under a - * time limit (the full-history backfill, which runs under its pg-boss job's - * budget and abort signal). A walk asks it before each request and, when it - * says stop, ends there and reports itself truncated, the same outcome as - * reaching the page ceiling: the resource is incomplete, the cycle is not - * stamped, and the reason shown is that only part of the history was - * processed. Outside such a scope the walks never stop early. - * - * A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`): - * minutes and pages rather than the job's hours. Only a walk that keeps its - * place across attempts (`resumable`) honours it, because only such a walk - * loses nothing by stopping; a walk that would start over from its first page - * runs on to the end under the job's limit alone. - */ -interface GoogleHealthWalkLimits { - shouldStop: () => boolean; - slice?: GoogleHealthSliceBudget; -} - -/** Why a backfill slice stopped walking. */ -export type GoogleHealthSliceStopReason = - "time_budget" | "page_budget" | "rate_limited" | "job_budget" | "lane_wanted"; - -/** - * One backfill slice's share of the work: a span of wall-clock time and a - * page count, both well inside the job's own expiry. The clock starts the - * first time a walk asks, which is after the slice has the account's lock: a - * slice that waited for an hourly sync to finish does not start out with its - * time already spent. `pages` counts every page the slice's walks have read; - * `stopReason` is set the first time a limit is reached and stays set, so - * every walk after it stops too. - */ -export interface GoogleHealthSliceBudget { - ms: number; - /** When the slice's time is up; null until the first walk asks. */ - deadline: number | null; - maxPages: number; - pages: number; - /** - * Of `pages`, those a walk only passed over on its way to its edge, because - * Google refused the edge filter (see `forEachDataPointPage`). They are not - * charged against `maxPages`. Their time is: a skip runs on the slice's - * clock like any other read, and on its own shorter limit - * (`GOOGLE_HEALTH_SKIP_MS`), and keeps its place for the next slice when it - * stops. Only a skip that had to start over from the newest page, because - * its saved place was refused, moves `deadline` on by the time it took. - */ - skippedPages: number; - stopReason: GoogleHealthSliceStopReason | null; - now: () => number; - /** - * Whether another heavy job waits for the lane this slice holds - * (`laneWanted` in `jobs/heavy-lane.ts`). Asked where the slice's own - * limits are, so giving way keeps every walk's place, and only once the - * slice has read a page, so the slice it ends has something on disk and is - * continued rather than failed. - */ - yieldWanted: () => boolean; -} - -/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */ -export function googleHealthSliceBudget( - ms: number, - maxPages: number, - now: () => number = Date.now, - yieldWanted: () => boolean = () => false, -): GoogleHealthSliceBudget { - return { - ms, - deadline: null, - maxPages, - pages: 0, - skippedPages: 0, - stopReason: null, - now, - yieldWanted, - }; -} - -const stopStorage = new AsyncLocalStorage(); - -/** Whether the slice in scope has used its budget (and why, once it has). */ -function sliceExhausted(slice: GoogleHealthSliceBudget): boolean { - if (slice.stopReason) return true; - slice.deadline ??= slice.now() + slice.ms; - if (slice.now() >= slice.deadline) slice.stopReason = "time_budget"; - else if (slice.pages - slice.skippedPages >= slice.maxPages) { - slice.stopReason = "page_budget"; - } else if (slice.pages > 0 && slice.yieldWanted()) { - slice.stopReason = "lane_wanted"; - } - return slice.stopReason !== null; -} - -/** - * Whether a walk should stop before its next request. Every walk stops on the - * job's limit; a resumable one also stops on the slice's. - */ -function stopRequested(resumable = false): boolean { - const limits = stopStorage.getStore(); - if (!limits) return false; - if (limits.shouldStop()) { - if (limits.slice && !limits.slice.stopReason) { - limits.slice.stopReason = "job_budget"; - } - return true; - } - return ( - resumable && limits.slice !== undefined && sliceExhausted(limits.slice) - ); -} - -/** - * Whether the backfill slice in scope has used its budget. A collection that - * has not started yet waits for the next slice rather than starting now. - * False outside a slice. - */ -export function googleHealthSliceExhausted(): boolean { - const limits = stopStorage.getStore(); - if (!limits) return false; - if (limits.shouldStop()) { - if (limits.slice && !limits.slice.stopReason) { - limits.slice.stopReason = "job_budget"; - } - return true; - } - return limits.slice !== undefined && sliceExhausted(limits.slice); -} - -/** Pages the slice in scope has read so far (0 outside a slice). */ -export function googleHealthSlicePagesRead(): number { - return stopStorage.getStore()?.slice?.pages ?? 0; -} - -/** Count one page read against the slice in scope, if any. */ -function noteSlicePage(): void { - const slice = stopStorage.getStore()?.slice; - if (slice) slice.pages += 1; -} - -/** - * Note one page the walk only passed over on its way to its edge: read, but - * not charged against the slice's pages (no-op outside a slice). - */ -function noteSliceSkip(): void { - const slice = stopStorage.getStore()?.slice; - if (slice) slice.skippedPages += 1; -} - -/** - * Give back to the slice in scope the time a skip took that had to start over - * from the newest page (no-op outside a slice). - */ -function extendSliceDeadline(ms: number): void { - const slice = stopStorage.getStore()?.slice; - if (slice && slice.deadline !== null) slice.deadline += ms; -} - -/** The slice clock in scope, or the wall clock outside a slice. */ -function sliceNow(): number { - return stopStorage.getStore()?.slice?.now() ?? Date.now(); -} - -/** End the slice in scope for `reason` (no-op outside a slice). */ -function endSlice(reason: GoogleHealthSliceStopReason): void { - const slice = stopStorage.getStore()?.slice; - if (slice && !slice.stopReason) slice.stopReason = reason; -} - -/** Whether a resumable walk can stop and keep its place here. */ -function inSlice(): boolean { - return stopStorage.getStore()?.slice !== undefined; -} - -export async function runWithGoogleHealthStop( - shouldStop: () => boolean, - fn: () => Promise, - slice?: GoogleHealthSliceBudget, -): Promise { - return stopStorage.run({ shouldStop, slice }, fn); -} - -/** - * Mark the resource in scope truncated without a walk: a collection a slice - * left for the next one. The cycle then reports itself incomplete, so the - * backfill does not mistake a skipped collection for an empty one. - */ -export function noteGoogleHealthDeferred(): void { - notePagination(0, 0, true); -} - -export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; +import { notePagination, stopRequested, noteSlicePage } from "./budget"; +import { + extractGoogleApiErrorDetail, + GoogleHealthAccessToken, + googleHealthDataRequest, +} from "./request"; +import { GOOGLE_HEALTH_API_BASE } from "./request"; /** * The Google Health profile carries the external user id used as the @@ -396,910 +104,6 @@ export function resolveGoogleHealthUserId( return "me"; } -// ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ────── -// -// The Google Health API exposes one uniform `DataPoint` resource shape across -// every data type. Spot / daily-summary / session types are read through -// `GET /v4/users/me/dataTypes/{dataType}/dataPoints` with `nextPageToken` -// pagination; the cumulative activity types (steps / distance / active energy / -// floors) are read through `POST …/dataPoints:dailyRollUp` — their `list` -// surface returns minute-grain observation buckets (or, for floors, does not -// exist at all), so the daily totals MUST come from the rollup. -// -// Casing gotcha (three encodings per type, all pinned in -// `GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up): -// - request path: kebab-case (`body-fat`) -// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`) — -// EXCEPT daily-summary `.date` filters, where the docs contradict -// themselves and the worked example uses the camelCase payload key; the -// client sends camel first and falls back to snake on a first-page 400 -// (see `GoogleHealthDateFilterStyle`). -// - response payload: camelCase — the `DataPoint` value is a union keyed by -// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with -// camelCase nested objects (`sampleTime.physicalTime`, -// `interval.startTime`, `civilStartTime.date`). -// - -// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric -// extractor coerces numeric strings before the finite check. - -/** - * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to - * 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25. The launch - * metrics use the daily/spot reads, so the default page size is the larger. - */ -export const GOOGLE_HEALTH_PAGE_SIZE = 1000; -/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ -export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; - -/** - * Page ceiling for one intraday heart-rate walk. - * - * Per walk, never across walks: a backfill reads at most a slice's pages per - * walk and keeps its place between slices, and an hourly sync reads a few - * pages. What stops a walk that keeps going without getting anywhere is the - * progress guard (`GOOGLE_HEALTH_NO_PROGRESS_PAGES`), and what bounds a - * backfill that does get somewhere is its horizon in years (see - * `backfill-bounds.ts`). The walk is paged (`forEachDataPointPage`), so the - * ceiling costs time, not memory. - */ -export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000; - -/** Page ceiling for every other list walk. */ -export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000; - -/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ -interface GoogleHealthDataPointPage { - dataPoints?: GoogleHealthDataPoint[]; - nextPageToken?: string | null; -} - -interface DataPointQuery { - /** Lower-bound incremental cursor; omitted on a full backfill. */ - start?: Date; - /** Page size (defaults to `GOOGLE_HEALTH_PAGE_SIZE`). */ - pageSize?: number; - /** Hard ceiling on pages walked (defence against a runaway cursor). */ - maxPages?: number; - /** - * The user's IANA zone — needed only by the `civilStart` filter (the - * offset-less civil bound must be the watermark's wall clock in the USER'S - * zone). Omitted → the bound forms in UTC. - */ - tz?: string; - /** - * Observes which daily-summary `.date` filter prefix style the walk settled - * on (`camel` worked-example vs `snake` fallback) — the structure probe - * surfaces it so a live account reports which grammar Google accepted. - */ - onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void; - /** - * The walk keeps its place across attempts (a backfill's resume cursor), so - * it may stop on the backfill slice's budget and on a rate limit it cannot - * wait out, and report itself truncated instead of failing: the next slice - * picks up from the edge it recorded. - */ - resumable?: boolean; - /** - * Where an earlier attempt of this walk got to: the oldest sample it wrote - * when the walk ran newest first (`desc`), the newest when it ran oldest - * first (`asc`). The walk asks only for the samples at or beyond it; the - * one sample on the edge itself is read again and overwrites in place. - * - * `filter: false` reads without the edge bound and passes over the samples - * beyond the edge here instead (see `forEachDataPointPage`); the walk falls - * back to that on its own when Google refuses the bound, and calls - * `onFilterUnsupported` once when it does. `tz` reads an offset-less sample - * time the way the edge was recorded. Passing over has no page bound of - * its own: the pages passed over move toward the edge, and a walk whose - * pages stop moving is stopped by the progress guard like any other. - * - * A walk without the bound keeps its place in that list across slices: - * `resumeFrom` is where an earlier slice's walk stopped, and - * `onResumePoint` is told where this one stops (see - * `GoogleHealthResumePoint`). - */ - edge?: { - at: Date; - order: "desc" | "asc"; - filter?: boolean; - tz?: string; - onFilterUnsupported?: () => void | Promise; - resumeFrom?: GoogleHealthResumePoint; - onResumePoint?: (point: GoogleHealthResumePoint) => void | Promise; - }; - /** - * Told once when the walk is stopped for going - * `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row without reaching any - * further back (see `backfill-bounds.ts`). The walk then reports itself - * truncated, as it does on its page ceiling. - */ - onNoProgress?: () => void | Promise; -} - -/** - * Where a walk without the edge bound stopped in the unbounded list: the page - * token that fetched the last page it read, and that page's position (pages - * from the newest one, which is position 0). The next slice starts by reading - * that page again rather than the newest one, so it passes over only what is - * left between it and the edge instead of everything earlier slices wrote. - * - * Re-reading the last page, not the one after it, is what lets the walk check - * the token still points where it did: the page must hold a sample at or - * beyond the edge. A list that lost samples in between could otherwise move - * the token past samples nobody wrote. A page that fails the check, or a - * token Google refuses, sends the walk back to the newest page, the way it - * read before it kept its place. - */ -export interface GoogleHealthResumePoint { - token: string; - page: number; -} - -/** - * The longest a walk passes over samples beyond its edge in one slice before - * it stops and keeps its place for the next. Shorter than the slice, so a - * slice that has to skip still writes, and never longer than it: a skip runs - * on the slice's clock and the account's lock is held no longer than a slice. - */ -export const GOOGLE_HEALTH_SKIP_MS = 3 * 60 * 1000; - -const pad2 = (n: number): string => String(n).padStart(2, "0"); - -/** - * Format a UTC instant as the offset-less civil wall clock an observer in `tz` - * reads at that moment (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) — the bound - * format the session `civil_start_time` filter expects. Without `tz` the bound - * forms in UTC. - */ -export function formatCivilBound(instant: Date, tz?: string): string { - if (!tz) return instant.toISOString().slice(0, 19); - const p = wallClockInTz(instant, tz); - return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`; -} - -/** - * The daily-summary `.date` filter prefix style. The official docs contradict - * themselves on this one point: - * - The data-types index states "In a filter parameter … the data type name - * must be in snake case, such as `body_fat`" and its per-type "filter - * parameter" column lists `daily_heart_rate_variability` etc. - * - The `dataPoints.list` reference's ONLY worked daily-summary example is - * `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload - * union key, NOT snake_case. - * Live behaviour: the snake_case daily filter is accepted (HTTP 200) but has - * returned zero rows on accounts whose companion app visibly holds daily HRV / - * RHR — consistent with a vacuously-unmatched predicate. The client therefore - * sends the worked-example camelCase form first and falls back to snake_case - * once if the very first page is rejected with a 400 (see `fetchDataPoints`). - */ -export type GoogleHealthDateFilterStyle = "camel" | "snake"; - -/** - * Build the incremental `filter` field + bound for one list-read data type. - * Centralised so the predicate and the read-time anchor resolution can never - * drift. The legal filter fields are per-shape (anything else 400s): - * - `sample` → `{filter}.sample_time.physical_time` (RFC-3339). - * - `date` → `{key|filter}.date` (`YYYY-MM-DD`) — prefix style per - * `GoogleHealthDateFilterStyle` (docs conflict; camel is the worked - * example, snake the fallback). - * - `sessionEnd` → sleep only: `{filter}.interval.end_time` (RFC-3339) — the - * ONLY filterable time field on sleep; watermark semantics improve too (a - * night is fetched when it ENDS after the cursor). - * - `civilStart` → exercise: `{filter}.interval.civil_start_time` with an - * offset-less civil bound in the user's zone. - * `rollup` types never build a list filter — they read via `:dailyRollUp`. - */ -export function incrementalFilter( - dataType: GoogleHealthDataType, - start: Date, - tz?: string, - dateStyle: GoogleHealthDateFilterStyle = "camel", -): { field: string; bound: string } { - switch (dataType.timeField) { - case "sample": - return { - field: `${dataType.filter}.sample_time.physical_time`, - bound: start.toISOString(), - }; - case "sessionEnd": - return { - field: `${dataType.filter}.interval.end_time`, - bound: start.toISOString(), - }; - case "civilStart": - return { - field: `${dataType.filter}.interval.civil_start_time`, - bound: formatCivilBound(start, tz), - }; - case "date": - return { - field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`, - // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates - bound: start.toISOString().slice(0, 10), - }; - case "rollup": - throw new Error( - `Google Health data type ${dataType.path} reads via :dailyRollUp, not dataPoints.list`, - ); - } -} - -/** - * Redact + bound an upstream Google API error message before it reaches an - * error object / wide event: strip bearer tokens, drop query strings from any - * embedded URL (they can carry filters echoing user data or tokens), cap at - * 200 chars. - */ -function redactApiErrorMessage(msg: string): string { - return msg - .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") - .replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]") - .slice(0, 200); -} - -/** - * Collect the `google.rpc.BadRequest` field violations out of an AIP-193 - * `error.details[]` array as `field: description` fragments. Only the field - * PATH and the description sentence survive — both run through the message - * redactor and a per-fragment cap, and at most three violations are kept, so - * no request value can ride an error string into the logs. - */ -function extractFieldViolations(details: unknown): string[] { - if (!Array.isArray(details)) return []; - const out: string[] = []; - for (const d of details) { - if (!d || typeof d !== "object") continue; - const fv = (d as { fieldViolations?: unknown }).fieldViolations; - if (!Array.isArray(fv)) continue; - for (const v of fv) { - if (out.length >= 3) return out; - if (!v || typeof v !== "object") continue; - const o = v as { field?: unknown; description?: unknown }; - const field = - typeof o.field === "string" - ? redactApiErrorMessage(o.field).slice(0, 80) - : undefined; - const description = - typeof o.description === "string" - ? redactApiErrorMessage(o.description).slice(0, 120) - : undefined; - if (!field && !description) continue; - out.push([field, description].filter(Boolean).join(": ")); - } - } - return out; -} - -/** - * Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`) - * from a non-2xx Google Health body into a short redacted - * `STATUS: message [field: description]` detail string, or undefined when the - * body carries no such envelope. This is what makes a field-grammar 400 - * (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the - * `details[]` BadRequest field violations name the exact offending request - * field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` - * shape instead — handled in `postToken`.) - */ -export function extractGoogleApiErrorDetail(json: unknown): string | undefined { - if (!json || typeof json !== "object") return undefined; - const e = (json as { error?: unknown }).error; - if (!e || typeof e !== "object") return undefined; - const o = e as { status?: unknown; message?: unknown; details?: unknown }; - const status = typeof o.status === "string" ? o.status : undefined; - const message = - typeof o.message === "string" - ? redactApiErrorMessage(o.message) - : undefined; - const violations = extractFieldViolations(o.details); - if (!status && !message && violations.length === 0) return undefined; - const head = [status, message].filter(Boolean).join(": "); - const tail = violations.length > 0 ? `[${violations.join("; ")}]` : ""; - return [head, tail].filter(Boolean).join(" ").slice(0, 500); -} - -// ─── Token source + one data request ─────────────────────────── - -/** - * Where a walk gets its access token. A plain string is used as given. A - * function is asked before every request, so a walk that runs for longer than - * the token lives (an access token lasts an hour; a full-history heart-rate - * walk can take several) picks up the refreshed one instead of carrying an - * expired token into a 401. `forceRefresh` asks for a new token even though - * the current one has not reached its expiry, after Google refused it. - */ -export type GoogleHealthTokenSource = (opts: { - forceRefresh: boolean; -}) => Promise; - -export type GoogleHealthAccessToken = string | GoogleHealthTokenSource; - -/** - * How a data request sits out Google's per-user, per-minute quota (429 - * `RESOURCE_EXHAUSTED`). The quota refills within the minute, so a short wait - * turns a throttled request into a completed one, where failing it used to - * cost the whole collection. The waits follow `Retry-After` (or the - * `google.rpc.RetryInfo` delay in the body) when Google sends one, and - * otherwise double from `baseDelayMs`: 2, 4, 8, 16, 32 s, a minute and a - * bit in all, which spans a full quota window. A requested delay above - * `maxDelayMs` is not slept out; the request fails as the transient it is - * and the next run picks the window up. - */ -export const GOOGLE_HEALTH_RATE_LIMIT = { - maxRetries: 5, - baseDelayMs: 2_000, - maxDelayMs: 60_000, -} as const; - -/** - * The delay Google asked for on a 429, in ms: the `Retry-After` header in - * either of its forms, else a `google.rpc.RetryInfo` detail (`"30s"`) in the - * error body. Undefined when it asked for nothing readable. - */ -export function googleHealthRetryDelayMs( - res: Response, - json: unknown, - now: number = Date.now(), -): number | undefined { - const fromHeader = retryAfterMsOf(res, now); - if (fromHeader !== undefined) return fromHeader; - const details = (json as { error?: { details?: unknown } } | null)?.error - ?.details; - if (Array.isArray(details)) { - for (const d of details) { - const delay = (d as { retryDelay?: unknown } | null)?.retryDelay; - if (typeof delay === "string") { - const m = /^(\d+(?:\.\d+)?)s$/.exec(delay.trim()); - if (m) return Math.round(Number.parseFloat(m[1]!) * 1000); - } - } - } - return undefined; -} - -/** - * How a data request rides out a server that did not answer properly: a - * 500, 502, 503 or 504, a timeout, or a broken connection. A short history - * reads a handful of pages and rarely meets one; a full-history heart-rate - * walk reads thousands, and a single one used to end the walk and throw away - * everything it had read. Three tries more, 2, 4 and 8 s apart (or the - * `Retry-After` a 503 names, up to a minute), before the request fails. - */ -export const GOOGLE_HEALTH_TRANSIENT_RETRY = { - maxRetries: 3, - baseDelayMs: 2_000, -}; - -const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]); - -function waitMs(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -interface GoogleHealthDataResponse { - status: number; - json: T | null; - verdict: ReturnType; - apiErrorDetail: string | undefined; -} - -/** - * Send one data request and answer with the response it finally got. - * - * Three answers are handled here rather than handed back: - * - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent - * again, up to `maxRetries` times, unless the caller's stop condition - * says to end the walk. - * - a server error or no answer at all (see - * `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count. - * - 401 with a token source: the token expired or was revoked between - * the source's last check and this request. The source is asked once for - * a fresh token and the request is repeated. A second 401, or a 401 on a - * fixed string token, is returned like any other answer. - * - * Every attempt is recorded as its own external call, so the wide event - * shows the throttling and the waits rather than only their outcome. - */ -async function googleHealthDataRequest( - token: GoogleHealthAccessToken, - label: string, - send: (accessToken: string) => Promise, - expectedFallbackNote: ( - status: number, - apiErrorDetail: string | undefined, - ) => string | undefined = () => undefined, -): Promise> { - let rateLimited = 0; - let transient = 0; - let refreshed = false; - let forceRefresh = false; - for (;;) { - const accessToken = - typeof token === "string" ? token : await token({ forceRefresh }); - forceRefresh = false; - const started = performance.now(); - let res: Response; - try { - res = await send(accessToken); - } catch (err) { - // No answer at all: the request timed out or the connection broke. - // On a walk of a thousand pages one of those is a matter of time, and - // it cost the whole walk. - const retry = - err instanceof SafeFetchError && - (err.kind === "timeout" || err.kind === "network") && - transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && - !stopRequested(); - const delayMs = - GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient; - getEvent()?.addExternalCall({ - service: "google-health", - method: label, - duration_ms: Math.round(performance.now() - started), - ...(retry - ? { note: `no_response_retrying_in_ms=${delayMs}` } - : { error: err instanceof Error ? err.message : String(err) }), - }); - if (!retry) throw err; - transient += 1; - await waitMs(delayMs); - continue; - } - const json = (await res.json().catch(() => null)) as T | null; - const verdict = classifyGoogleHealthResponse(res.status); - const apiErrorDetail = - verdict.classification === "success" - ? undefined - : extractGoogleApiErrorDetail(json); - - const retryAfterMs = - res.status === 429 ? googleHealthRetryDelayMs(res, json) : undefined; - const backoffMs = - res.status === 429 - ? (retryAfterMs ?? - GOOGLE_HEALTH_RATE_LIMIT.baseDelayMs * 2 ** rateLimited) - : 0; - const willWait = - res.status === 429 && - rateLimited < GOOGLE_HEALTH_RATE_LIMIT.maxRetries && - backoffMs <= GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs && - !stopRequested(); - const willRefresh = - res.status === 401 && typeof token !== "string" && !refreshed; - const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) - ? Math.min( - googleHealthRetryDelayMs(res, json) ?? - GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient, - GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs, - ) - : 0; - const willRetryTransient = - GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) && - transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && - !stopRequested(); - - getEvent()?.addExternalCall({ - service: "google-health", - method: label, - duration_ms: Math.round(performance.now() - started), - status: res.status, - ...(expectedFallbackNote(res.status, apiErrorDetail) - ? { note: expectedFallbackNote(res.status, apiErrorDetail) } - : willWait - ? { note: `rate_limited_retrying_in_ms=${backoffMs}` } - : willRefresh - ? { note: "token_rejected_retrying_with_fresh_token" } - : willRetryTransient - ? { note: `server_error_retrying_in_ms=${transientDelayMs}` } - : { - error: - verdict.classification === "success" - ? undefined - : apiErrorDetail - ? `${verdict.reason} ${apiErrorDetail}` - : verdict.reason, - }), - }); - - if (willWait) { - rateLimited += 1; - await waitMs(backoffMs); - continue; - } - if (willRetryTransient) { - transient += 1; - await waitMs(transientDelayMs); - continue; - } - if (willRefresh) { - refreshed = true; - forceRefresh = true; - continue; - } - return { status: res.status, json, verdict, apiErrorDetail }; - } -} - -/** - * Walk every `DataPoint` for one data type since the incremental cursor via - * `dataPoints.list` with `nextPageToken` pagination. The data-type id is - * kebab-cased in the path; the `filter` predicate is built per-shape against - * the type's time anchor. `rollup` types refuse — they read via - * `fetchDailyRollUp`. - * - * DAILY-SUMMARY FILTER FALLBACK: the docs contradict themselves on the `.date` - * filter's type-name prefix (the index's "filter parameter" column says - * snake_case; the list reference's only worked example says camelCase — see - * `GoogleHealthDateFilterStyle`). The walk sends the camelCase worked-example - * form first; if the VERY FIRST request of the walk is rejected with a 400, - * the whole walk retries once with the snake_case form. The style that - * succeeded is annotated as `googleHealth.dateFilter.style` so live behaviour - * reports which grammar Google actually accepts. Any other failure, or a 400 - * past the first request, propagates. - */ -export async function fetchDataPoints( - dataType: GoogleHealthDataType, - accessToken: GoogleHealthAccessToken, - verb: string, - query: DataPointQuery = {}, -): Promise { - const points: GoogleHealthDataPoint[] = []; - await forEachDataPointPage(dataType, accessToken, verb, query, (page) => { - for (const p of page) points.push(p); - }); - return points; -} - -/** - * The same walk as `fetchDataPoints`, handing each page to `onPage` as it - * arrives instead of collecting the whole collection first. - * - * A dense collection has to be read this way. Intraday heart rate is one - * point a minute, so a full-history backfill of two years is over a million - * points; held as parsed JSON and then again as mapped readings, that is more - * than a 1 GB heap, and the process that runs the backfill also serves the - * app. Page by page, only one page is ever resident. - * - * `onPage` is awaited before the next page is requested, so a slow write - * applies back-pressure to the fetch. The date-filter fallback still works: - * it only fires when the first request is rejected, which is before any page - * reaches `onPage`. - * - * `onPage` answers `"end"` when the caller has read as far as it wants (a - * backfill that reached its horizon): the walk ends there as complete, not - * truncated. - * - * A walk over spot readings (`timeField: "sample"`) also watches whether it - * is getting anywhere: `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row that - * reach no further back than the pages before them end it, truncated, and - * tell `onNoProgress` (see `backfill-bounds.ts`). - */ -export async function forEachDataPointPage( - dataType: GoogleHealthDataType, - accessToken: GoogleHealthAccessToken, - verb: string, - query: DataPointQuery, - onPage: ( - points: GoogleHealthDataPoint[], - ) => void | "end" | Promise, -): Promise { - const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; - const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; - const resumable = query.resumable === true; - const edge = query.edge; - - let requestCount = 0; - - const walk = async ( - dateStyle: GoogleHealthDateFilterStyle, - edgeBy: "filter" | "skip", - resumeFrom: GoogleHealthResumePoint | undefined, - restarted: boolean, - ): Promise => { - let fetched = 0; - let pageToken: string | null | undefined = resumeFrom?.token; - // The token that fetched the page last read, and that page's position. - let lastToken: string | undefined; - let lastPosition = -1; - let pageCount = 0; - let stopped = false; - // Reading without the edge bound: the pages before the edge are passed - // over, not written, until the first sample at or beyond it. - const skipTo = edge && edgeBy === "skip" ? edge : undefined; - let crossed = skipTo === undefined; - // Pages passed over in this walk. Never reset: the walk's page ceiling - // counts the pages it wrote, `pageCount - skipped`, before the crossing - // and after it alike. - let skipped = 0; - const startPosition = resumeFrom?.page ?? 0; - const skipStartedAt = skipTo ? sliceNow() : 0; - // Whether the walk is still reaching further back. A walk filtered to - // the edge starts from it; one passing over the samples beyond the edge - // starts from nothing, since those pages move toward the edge too. - const progress = - dataType.timeField === "sample" - ? googleHealthWalkProgress(edge && edgeBy === "filter" ? edge : null) - : null; - const progressTz = edge?.tz ?? query.tz; - let noProgress = false; - const sampleAt = (point: GoogleHealthDataPoint): number | null => - googleHealthSampleInstant(point, dataType, skipTo?.tz)?.getTime() ?? null; - const beyondEdge = (point: GoogleHealthDataPoint): boolean | null => { - const t = sampleAt(point); - if (t === null || !skipTo) return null; - return skipTo.order === "desc" - ? t > skipTo.at.getTime() - : t < skipTo.at.getTime(); - }; - const reachesEdge = (point: GoogleHealthDataPoint): boolean => { - const t = sampleAt(point); - if (t === null || !skipTo) return false; - return skipTo.order === "desc" - ? t >= skipTo.at.getTime() - : t <= skipTo.at.getTime(); - }; - - do { - // A skip that had to start over from the newest page runs as the walk - // did before it kept its place: on the job's budget alone, its time - // given back to the slice once it crosses. Every other read stops on - // the slice's budget, and a skip also on its own shorter limit; it - // keeps its place below, so the next slice goes on from there. - const untimed = !crossed && restarted; - if (untimed ? stopRequested(false) : stopRequested(resumable)) { - stopped = true; - break; - } - if ( - !crossed && - !untimed && - resumable && - inSlice() && - sliceNow() - skipStartedAt >= GOOGLE_HEALTH_SKIP_MS - ) { - stopped = true; - break; - } - const params = new URLSearchParams({ pageSize: String(pageSize) }); - const filters: string[] = []; - if (query.start) { - const { field, bound } = incrementalFilter( - dataType, - query.start, - query.tz, - dateStyle, - ); - filters.push(`${field} >= "${bound}"`); - } - const edgeFiltered = edge !== undefined && edgeBy === "filter"; - if (edgeFiltered) { - const { field, bound } = incrementalFilter( - dataType, - edge.at, - query.tz, - dateStyle, - ); - filters.push( - `${field} ${edge.order === "desc" ? "<=" : ">="} "${bound}"`, - ); - } - if (filters.length > 0) params.set("filter", filters.join(" AND ")); - if (pageToken) params.set("pageToken", pageToken); - - requestCount += 1; - // v1.42 (#1023) — the first filtered daily-summary read answering 400 - // is the expected trigger for the snake_case retry below, not a - // failure: some accounts reject the documented camelCase prefix on - // some types, and the very next request answers 200. It was logged as - // an error next to the 200 that followed it. It is recorded as a note - // now; any other 400 still carries its error. The same holds for the - // first resumed request refusing the edge bound, and for a saved - // place in the unbounded list Google no longer takes. - const firstRequest = requestCount === 1; - const firstOfWalk = pageCount === 0; - const edgeRefused = (status: number, detail: string | undefined) => - edgeFiltered && - firstOfWalk && - status === 400 && - refusesEdgeFilter(detail); - const resumeRefused = (status: number) => - resumeFrom !== undefined && firstOfWalk && status === 400; - const requestToken = pageToken ?? undefined; - const { status, json, verdict, apiErrorDetail } = - await googleHealthDataRequest( - accessToken, - `${verb}(page=${pageCount})`, - (token) => - safeFetch( - `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, - { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }, - ), - (status, detail) => - canFallBack && - dateStyle === "camel" && - firstRequest && - status === 400 - ? "date_filter_rejected_retrying_snake_case" - : edgeRefused(status, detail) - ? "edge_filter_rejected_reading_without_it" - : resumeRefused(status) - ? "resume_token_rejected_reading_from_newest" - : undefined, - ); - if (edgeRefused(status, apiErrorDetail)) { - throw new EdgeFilterRefused(); - } - if (resumeRefused(status)) throw new ResumePointLost(); - // A rate limit the request could not wait out ends a resumable walk - // inside a backfill slice where it is: the pages before it are written - // and their edge recorded, so this is a pause, not a failure. The slice - // ends with it, since every other walk would meet the same quota. - if (status === 429 && resumable && inSlice()) { - endSlice("rate_limited"); - stopped = true; - break; - } - if (verdict.classification !== "success") { - throw new GoogleHealthApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: apiErrorDetail, - }); - } - - let page = json?.dataPoints ?? []; - // A saved place has to still hold a sample at or beyond the edge; - // past it, samples nobody wrote could lie behind the token. - if (resumeFrom && firstOfWalk && !page.some(reachesEdge)) { - throw new ResumePointLost(); - } - if (requestToken !== undefined) { - lastToken = requestToken; - lastPosition = startPosition + pageCount; - } - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - fetched += page.length; - noteSlicePage(); - if ( - progress && - noteGoogleHealthWalkPage( - progress, - page.map( - (p) => - googleHealthSampleInstant(p, dataType, progressTz)?.getTime() ?? - null, - ), - ) - ) { - getEvent()?.addWarning( - `google-health ${verb}: ${GOOGLE_HEALTH_NO_PROGRESS_PAGES} pages in a row reached no further back than ${progress.furthest === null ? "the first" : new Date(progress.furthest).toISOString()}; walk stopped`, - ); - noProgress = true; - stopped = true; - break; - } - if (!crossed) { - // Placed samples beyond the edge were written by an earlier slice; - // one without a sample time cannot be placed and is passed over - // with them until the walk is past the edge. - const kept = page.filter((p) => beyondEdge(p) === false); - if (kept.length === 0) { - skipped += 1; - noteSliceSkip(); - continue; - } - crossed = true; - if (restarted) extendSliceDeadline(sliceNow() - skipStartedAt); - page = page.filter((p) => beyondEdge(p) !== true); - } - if ((await onPage(page)) === "end") { - pageToken = null; - break; - } - } while (pageToken && pageCount - skipped < maxPages); - - const truncated = stopped || Boolean(pageToken); - // Keep the place for the next slice. Only a walk without the bound has - // one, and only one that ended short needs it. - if (skipTo && truncated && lastToken !== undefined) { - await skipTo.onResumePoint?.({ token: lastToken, page: lastPosition }); - } - notePagination(pageCount, fetched, truncated); - if (noProgress) await query.onNoProgress?.(); - }; - - // The fallback only exists for filtered daily-summary reads — every other - // shape has one documented, doc-consistent filter field. - const canFallBack = - dataType.timeField === "date" && query.start !== undefined; - - let style: GoogleHealthDateFilterStyle = "camel"; - let edgeBy: "filter" | "skip" = edge?.filter === false ? "skip" : "filter"; - // From the saved place in the unbounded list when there is one; once from - // the newest page when that place is lost. - const walkSkipping = async (dateStyle: GoogleHealthDateFilterStyle) => { - const resumeFrom = edge?.resumeFrom; - if (!resumeFrom) return walk(dateStyle, "skip", undefined, false); - try { - await walk(dateStyle, "skip", resumeFrom, false); - } catch (err) { - if (!(err instanceof ResumePointLost)) throw err; - await walk(dateStyle, "skip", undefined, true); - } - }; - const run = async (dateStyle: GoogleHealthDateFilterStyle) => { - if (edgeBy === "skip") return walkSkipping(dateStyle); - try { - await walk(dateStyle, edgeBy, undefined, false); - } catch (err) { - if (!(err instanceof EdgeFilterRefused)) throw err; - // Once: the walk without the bound sends no edge filter to refuse. - edgeBy = "skip"; - await edge?.onFilterUnsupported?.(); - await walk(dateStyle, edgeBy, undefined, false); - } - }; - try { - await run("camel"); - } catch (err) { - const firstRequestRejected = - canFallBack && - requestCount === 1 && - err instanceof GoogleHealthApiError && - err.httpStatus === 400; - if (!firstRequestRejected) throw err; - style = "snake"; - await run("snake"); - } - if (canFallBack) { - annotate({ meta: { "googleHealth.dateFilter.style": style } }); - query.onDateFilterStyle?.(style); - } -} - -/** - * Thrown inside a walk whose first request Google refused because of the - * resume edge's bound; `forEachDataPointPage` reads the collection again - * without it. - */ -class EdgeFilterRefused extends Error { - constructor() { - super("google-health: edge filter refused"); - } -} - -/** - * Thrown inside a walk that started from a saved place in the unbounded list - * (`GoogleHealthResumePoint`) when Google refused the token or the page it - * returned no longer reaches the edge; the walk starts over from the newest - * page. - */ -class ResumePointLost extends Error { - constructor() { - super("google-health: saved place in the list lost"); - } -} - -/** - * Whether a 400 on the first resumed request is Google refusing the edge - * bound, rather than something else about the request. The request differs - * from one Google answers every hour only by that bound, so a bare 400 or an - * INVALID_ARGUMENT counts, and so does any message naming the filter; one - * naming the page token, the page size or the access token does not. - */ -export function refusesEdgeFilter(detail: string | undefined): boolean { - if (detail === undefined) return true; - if (/filter/i.test(detail)) return true; - if (/token|page.?size/i.test(detail)) return false; - return /INVALID_ARGUMENT/.test(detail); -} - // ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ───────────────── /** diff --git a/src/lib/google-health/data-points.ts b/src/lib/google-health/data-points.ts new file mode 100644 index 000000000..5b095c01b --- /dev/null +++ b/src/lib/google-health/data-points.ts @@ -0,0 +1,565 @@ +/** + * The paginated `dataPoints.list` walker: page by page under the budget in + * scope (`./budget.ts`), resumable from a saved place, falling back when + * Google refuses an edge filter, and the collecting `fetchDataPoints` built + * on it. + */ +import { annotate, getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; +import { GoogleHealthApiError } from "./response-classifier"; +import { + googleHealthSampleInstant, + type GoogleHealthDataPoint, + type GoogleHealthDataType, +} from "./mappers"; +import { + googleHealthWalkProgress, + noteGoogleHealthWalkPage, +} from "./backfill-bounds"; +import { + notePagination, + stopRequested, + noteSlicePage, + noteSliceSkip, + extendSliceDeadline, + sliceNow, + endSlice, + inSlice, +} from "./budget"; +import { + GoogleHealthDateFilterStyle, + incrementalFilter, + GoogleHealthAccessToken, + googleHealthDataRequest, +} from "./request"; +import { GOOGLE_HEALTH_API_BASE } from "./request"; +import { GOOGLE_HEALTH_NO_PROGRESS_PAGES } from "./backfill-bounds"; + +// ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ────── +// +// The Google Health API exposes one uniform `DataPoint` resource shape across +// every data type. Spot / daily-summary / session types are read through +// `GET /v4/users/me/dataTypes/{dataType}/dataPoints` with `nextPageToken` +// pagination; the cumulative activity types (steps / distance / active energy / +// floors) are read through `POST …/dataPoints:dailyRollUp` — their `list` +// surface returns minute-grain observation buckets (or, for floors, does not +// exist at all), so the daily totals MUST come from the rollup. +// +// Casing gotcha (three encodings per type, all pinned in +// `GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up): +// - request path: kebab-case (`body-fat`) +// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`) — +// EXCEPT daily-summary `.date` filters, where the docs contradict +// themselves and the worked example uses the camelCase payload key; the +// client sends camel first and falls back to snake on a first-page 400 +// (see `GoogleHealthDateFilterStyle`). +// - response payload: camelCase — the `DataPoint` value is a union keyed by +// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with +// camelCase nested objects (`sampleTime.physicalTime`, +// `interval.startTime`, `civilStartTime.date`). +// + +// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric +// extractor coerces numeric strings before the finite check. + +/** + * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to + * 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25. The launch + * metrics use the daily/spot reads, so the default page size is the larger. + */ +export const GOOGLE_HEALTH_PAGE_SIZE = 1000; + +/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ +export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; + +/** + * Page ceiling for one intraday heart-rate walk. + * + * Per walk, never across walks: a backfill reads at most a slice's pages per + * walk and keeps its place between slices, and an hourly sync reads a few + * pages. What stops a walk that keeps going without getting anywhere is the + * progress guard (`GOOGLE_HEALTH_NO_PROGRESS_PAGES`), and what bounds a + * backfill that does get somewhere is its horizon in years (see + * `backfill-bounds.ts`). The walk is paged (`forEachDataPointPage`), so the + * ceiling costs time, not memory. + */ +export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000; + +/** Page ceiling for every other list walk. */ +export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000; + +/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ +interface GoogleHealthDataPointPage { + dataPoints?: GoogleHealthDataPoint[]; + nextPageToken?: string | null; +} + +interface DataPointQuery { + /** Lower-bound incremental cursor; omitted on a full backfill. */ + start?: Date; + /** Page size (defaults to `GOOGLE_HEALTH_PAGE_SIZE`). */ + pageSize?: number; + /** Hard ceiling on pages walked (defence against a runaway cursor). */ + maxPages?: number; + /** + * The user's IANA zone — needed only by the `civilStart` filter (the + * offset-less civil bound must be the watermark's wall clock in the USER'S + * zone). Omitted → the bound forms in UTC. + */ + tz?: string; + /** + * Observes which daily-summary `.date` filter prefix style the walk settled + * on (`camel` worked-example vs `snake` fallback) — the structure probe + * surfaces it so a live account reports which grammar Google accepted. + */ + onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void; + /** + * The walk keeps its place across attempts (a backfill's resume cursor), so + * it may stop on the backfill slice's budget and on a rate limit it cannot + * wait out, and report itself truncated instead of failing: the next slice + * picks up from the edge it recorded. + */ + resumable?: boolean; + /** + * Where an earlier attempt of this walk got to: the oldest sample it wrote + * when the walk ran newest first (`desc`), the newest when it ran oldest + * first (`asc`). The walk asks only for the samples at or beyond it; the + * one sample on the edge itself is read again and overwrites in place. + * + * `filter: false` reads without the edge bound and passes over the samples + * beyond the edge here instead (see `forEachDataPointPage`); the walk falls + * back to that on its own when Google refuses the bound, and calls + * `onFilterUnsupported` once when it does. `tz` reads an offset-less sample + * time the way the edge was recorded. Passing over has no page bound of + * its own: the pages passed over move toward the edge, and a walk whose + * pages stop moving is stopped by the progress guard like any other. + * + * A walk without the bound keeps its place in that list across slices: + * `resumeFrom` is where an earlier slice's walk stopped, and + * `onResumePoint` is told where this one stops (see + * `GoogleHealthResumePoint`). + */ + edge?: { + at: Date; + order: "desc" | "asc"; + filter?: boolean; + tz?: string; + onFilterUnsupported?: () => void | Promise; + resumeFrom?: GoogleHealthResumePoint; + onResumePoint?: (point: GoogleHealthResumePoint) => void | Promise; + }; + /** + * Told once when the walk is stopped for going + * `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row without reaching any + * further back (see `backfill-bounds.ts`). The walk then reports itself + * truncated, as it does on its page ceiling. + */ + onNoProgress?: () => void | Promise; +} + +/** + * Where a walk without the edge bound stopped in the unbounded list: the page + * token that fetched the last page it read, and that page's position (pages + * from the newest one, which is position 0). The next slice starts by reading + * that page again rather than the newest one, so it passes over only what is + * left between it and the edge instead of everything earlier slices wrote. + * + * Re-reading the last page, not the one after it, is what lets the walk check + * the token still points where it did: the page must hold a sample at or + * beyond the edge. A list that lost samples in between could otherwise move + * the token past samples nobody wrote. A page that fails the check, or a + * token Google refuses, sends the walk back to the newest page, the way it + * read before it kept its place. + */ +export interface GoogleHealthResumePoint { + token: string; + page: number; +} + +/** + * The longest a walk passes over samples beyond its edge in one slice before + * it stops and keeps its place for the next. Shorter than the slice, so a + * slice that has to skip still writes, and never longer than it: a skip runs + * on the slice's clock and the account's lock is held no longer than a slice. + */ +export const GOOGLE_HEALTH_SKIP_MS = 3 * 60 * 1000; + +/** + * Walk every `DataPoint` for one data type since the incremental cursor via + * `dataPoints.list` with `nextPageToken` pagination. The data-type id is + * kebab-cased in the path; the `filter` predicate is built per-shape against + * the type's time anchor. `rollup` types refuse — they read via + * `fetchDailyRollUp`. + * + * DAILY-SUMMARY FILTER FALLBACK: the docs contradict themselves on the `.date` + * filter's type-name prefix (the index's "filter parameter" column says + * snake_case; the list reference's only worked example says camelCase — see + * `GoogleHealthDateFilterStyle`). The walk sends the camelCase worked-example + * form first; if the VERY FIRST request of the walk is rejected with a 400, + * the whole walk retries once with the snake_case form. The style that + * succeeded is annotated as `googleHealth.dateFilter.style` so live behaviour + * reports which grammar Google actually accepts. Any other failure, or a 400 + * past the first request, propagates. + */ +export async function fetchDataPoints( + dataType: GoogleHealthDataType, + accessToken: GoogleHealthAccessToken, + verb: string, + query: DataPointQuery = {}, +): Promise { + const points: GoogleHealthDataPoint[] = []; + await forEachDataPointPage(dataType, accessToken, verb, query, (page) => { + for (const p of page) points.push(p); + }); + return points; +} + +/** + * The same walk as `fetchDataPoints`, handing each page to `onPage` as it + * arrives instead of collecting the whole collection first. + * + * A dense collection has to be read this way. Intraday heart rate is one + * point a minute, so a full-history backfill of two years is over a million + * points; held as parsed JSON and then again as mapped readings, that is more + * than a 1 GB heap, and the process that runs the backfill also serves the + * app. Page by page, only one page is ever resident. + * + * `onPage` is awaited before the next page is requested, so a slow write + * applies back-pressure to the fetch. The date-filter fallback still works: + * it only fires when the first request is rejected, which is before any page + * reaches `onPage`. + * + * `onPage` answers `"end"` when the caller has read as far as it wants (a + * backfill that reached its horizon): the walk ends there as complete, not + * truncated. + * + * A walk over spot readings (`timeField: "sample"`) also watches whether it + * is getting anywhere: `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row that + * reach no further back than the pages before them end it, truncated, and + * tell `onNoProgress` (see `backfill-bounds.ts`). + */ +export async function forEachDataPointPage( + dataType: GoogleHealthDataType, + accessToken: GoogleHealthAccessToken, + verb: string, + query: DataPointQuery, + onPage: ( + points: GoogleHealthDataPoint[], + ) => void | "end" | Promise, +): Promise { + const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; + const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; + const resumable = query.resumable === true; + const edge = query.edge; + + let requestCount = 0; + + const walk = async ( + dateStyle: GoogleHealthDateFilterStyle, + edgeBy: "filter" | "skip", + resumeFrom: GoogleHealthResumePoint | undefined, + restarted: boolean, + ): Promise => { + let fetched = 0; + let pageToken: string | null | undefined = resumeFrom?.token; + // The token that fetched the page last read, and that page's position. + let lastToken: string | undefined; + let lastPosition = -1; + let pageCount = 0; + let stopped = false; + // Reading without the edge bound: the pages before the edge are passed + // over, not written, until the first sample at or beyond it. + const skipTo = edge && edgeBy === "skip" ? edge : undefined; + let crossed = skipTo === undefined; + // Pages passed over in this walk. Never reset: the walk's page ceiling + // counts the pages it wrote, `pageCount - skipped`, before the crossing + // and after it alike. + let skipped = 0; + const startPosition = resumeFrom?.page ?? 0; + const skipStartedAt = skipTo ? sliceNow() : 0; + // Whether the walk is still reaching further back. A walk filtered to + // the edge starts from it; one passing over the samples beyond the edge + // starts from nothing, since those pages move toward the edge too. + const progress = + dataType.timeField === "sample" + ? googleHealthWalkProgress(edge && edgeBy === "filter" ? edge : null) + : null; + const progressTz = edge?.tz ?? query.tz; + let noProgress = false; + const sampleAt = (point: GoogleHealthDataPoint): number | null => + googleHealthSampleInstant(point, dataType, skipTo?.tz)?.getTime() ?? null; + const beyondEdge = (point: GoogleHealthDataPoint): boolean | null => { + const t = sampleAt(point); + if (t === null || !skipTo) return null; + return skipTo.order === "desc" + ? t > skipTo.at.getTime() + : t < skipTo.at.getTime(); + }; + const reachesEdge = (point: GoogleHealthDataPoint): boolean => { + const t = sampleAt(point); + if (t === null || !skipTo) return false; + return skipTo.order === "desc" + ? t >= skipTo.at.getTime() + : t <= skipTo.at.getTime(); + }; + + do { + // A skip that had to start over from the newest page runs as the walk + // did before it kept its place: on the job's budget alone, its time + // given back to the slice once it crosses. Every other read stops on + // the slice's budget, and a skip also on its own shorter limit; it + // keeps its place below, so the next slice goes on from there. + const untimed = !crossed && restarted; + if (untimed ? stopRequested(false) : stopRequested(resumable)) { + stopped = true; + break; + } + if ( + !crossed && + !untimed && + resumable && + inSlice() && + sliceNow() - skipStartedAt >= GOOGLE_HEALTH_SKIP_MS + ) { + stopped = true; + break; + } + const params = new URLSearchParams({ pageSize: String(pageSize) }); + const filters: string[] = []; + if (query.start) { + const { field, bound } = incrementalFilter( + dataType, + query.start, + query.tz, + dateStyle, + ); + filters.push(`${field} >= "${bound}"`); + } + const edgeFiltered = edge !== undefined && edgeBy === "filter"; + if (edgeFiltered) { + const { field, bound } = incrementalFilter( + dataType, + edge.at, + query.tz, + dateStyle, + ); + filters.push( + `${field} ${edge.order === "desc" ? "<=" : ">="} "${bound}"`, + ); + } + if (filters.length > 0) params.set("filter", filters.join(" AND ")); + if (pageToken) params.set("pageToken", pageToken); + + requestCount += 1; + // The first filtered daily-summary read answering 400 + // is the expected trigger for the snake_case retry below, not a + // failure: some accounts reject the documented camelCase prefix on + // some types, and the very next request answers 200. It was logged as + // an error next to the 200 that followed it. It is recorded as a note + // now; any other 400 still carries its error. The same holds for the + // first resumed request refusing the edge bound, and for a saved + // place in the unbounded list Google no longer takes. + const firstRequest = requestCount === 1; + const firstOfWalk = pageCount === 0; + const edgeRefused = (status: number, detail: string | undefined) => + edgeFiltered && + firstOfWalk && + status === 400 && + refusesEdgeFilter(detail); + const resumeRefused = (status: number) => + resumeFrom !== undefined && firstOfWalk && status === 400; + const requestToken = pageToken ?? undefined; + const { status, json, verdict, apiErrorDetail } = + await googleHealthDataRequest( + accessToken, + `${verb}(page=${pageCount})`, + (token) => + safeFetch( + `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, + { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }, + ), + (status, detail) => + canFallBack && + dateStyle === "camel" && + firstRequest && + status === 400 + ? "date_filter_rejected_retrying_snake_case" + : edgeRefused(status, detail) + ? "edge_filter_rejected_reading_without_it" + : resumeRefused(status) + ? "resume_token_rejected_reading_from_newest" + : undefined, + ); + if (edgeRefused(status, apiErrorDetail)) { + throw new EdgeFilterRefused(); + } + if (resumeRefused(status)) throw new ResumePointLost(); + // A rate limit the request could not wait out ends a resumable walk + // inside a backfill slice where it is: the pages before it are written + // and their edge recorded, so this is a pause, not a failure. The slice + // ends with it, since every other walk would meet the same quota. + if (status === 429 && resumable && inSlice()) { + endSlice("rate_limited"); + stopped = true; + break; + } + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: apiErrorDetail, + }); + } + + let page = json?.dataPoints ?? []; + // A saved place has to still hold a sample at or beyond the edge; + // past it, samples nobody wrote could lie behind the token. + if (resumeFrom && firstOfWalk && !page.some(reachesEdge)) { + throw new ResumePointLost(); + } + if (requestToken !== undefined) { + lastToken = requestToken; + lastPosition = startPosition + pageCount; + } + pageToken = json?.nextPageToken ?? null; + pageCount += 1; + fetched += page.length; + noteSlicePage(); + if ( + progress && + noteGoogleHealthWalkPage( + progress, + page.map( + (p) => + googleHealthSampleInstant(p, dataType, progressTz)?.getTime() ?? + null, + ), + ) + ) { + getEvent()?.addWarning( + `google-health ${verb}: ${GOOGLE_HEALTH_NO_PROGRESS_PAGES} pages in a row reached no further back than ${progress.furthest === null ? "the first" : new Date(progress.furthest).toISOString()}; walk stopped`, + ); + noProgress = true; + stopped = true; + break; + } + if (!crossed) { + // Placed samples beyond the edge were written by an earlier slice; + // one without a sample time cannot be placed and is passed over + // with them until the walk is past the edge. + const kept = page.filter((p) => beyondEdge(p) === false); + if (kept.length === 0) { + skipped += 1; + noteSliceSkip(); + continue; + } + crossed = true; + if (restarted) extendSliceDeadline(sliceNow() - skipStartedAt); + page = page.filter((p) => beyondEdge(p) !== true); + } + if ((await onPage(page)) === "end") { + pageToken = null; + break; + } + } while (pageToken && pageCount - skipped < maxPages); + + const truncated = stopped || Boolean(pageToken); + // Keep the place for the next slice. Only a walk without the bound has + // one, and only one that ended short needs it. + if (skipTo && truncated && lastToken !== undefined) { + await skipTo.onResumePoint?.({ token: lastToken, page: lastPosition }); + } + notePagination(pageCount, fetched, truncated); + if (noProgress) await query.onNoProgress?.(); + }; + + // The fallback only exists for filtered daily-summary reads — every other + // shape has one documented, doc-consistent filter field. + const canFallBack = + dataType.timeField === "date" && query.start !== undefined; + + let style: GoogleHealthDateFilterStyle = "camel"; + let edgeBy: "filter" | "skip" = edge?.filter === false ? "skip" : "filter"; + // From the saved place in the unbounded list when there is one; once from + // the newest page when that place is lost. + const walkSkipping = async (dateStyle: GoogleHealthDateFilterStyle) => { + const resumeFrom = edge?.resumeFrom; + if (!resumeFrom) return walk(dateStyle, "skip", undefined, false); + try { + await walk(dateStyle, "skip", resumeFrom, false); + } catch (err) { + if (!(err instanceof ResumePointLost)) throw err; + await walk(dateStyle, "skip", undefined, true); + } + }; + const run = async (dateStyle: GoogleHealthDateFilterStyle) => { + if (edgeBy === "skip") return walkSkipping(dateStyle); + try { + await walk(dateStyle, edgeBy, undefined, false); + } catch (err) { + if (!(err instanceof EdgeFilterRefused)) throw err; + // Once: the walk without the bound sends no edge filter to refuse. + edgeBy = "skip"; + await edge?.onFilterUnsupported?.(); + await walk(dateStyle, edgeBy, undefined, false); + } + }; + try { + await run("camel"); + } catch (err) { + const firstRequestRejected = + canFallBack && + requestCount === 1 && + err instanceof GoogleHealthApiError && + err.httpStatus === 400; + if (!firstRequestRejected) throw err; + style = "snake"; + await run("snake"); + } + if (canFallBack) { + annotate({ meta: { "googleHealth.dateFilter.style": style } }); + query.onDateFilterStyle?.(style); + } +} + +/** + * Thrown inside a walk whose first request Google refused because of the + * resume edge's bound; `forEachDataPointPage` reads the collection again + * without it. + */ +class EdgeFilterRefused extends Error { + constructor() { + super("google-health: edge filter refused"); + } +} + +/** + * Thrown inside a walk that started from a saved place in the unbounded list + * (`GoogleHealthResumePoint`) when Google refused the token or the page it + * returned no longer reaches the edge; the walk starts over from the newest + * page. + */ +class ResumePointLost extends Error { + constructor() { + super("google-health: saved place in the list lost"); + } +} + +/** + * Whether a 400 on the first resumed request is Google refusing the edge + * bound, rather than something else about the request. The request differs + * from one Google answers every hour only by that bound, so a bare 400 or an + * INVALID_ARGUMENT counts, and so does any message naming the filter; one + * naming the page token, the page size or the access token does not. + */ +export function refusesEdgeFilter(detail: string | undefined): boolean { + if (detail === undefined) return true; + if (/filter/i.test(detail)) return true; + if (/token|page.?size/i.test(detail)) return false; + return /INVALID_ARGUMENT/.test(detail); +} diff --git a/src/lib/google-health/request.ts b/src/lib/google-health/request.ts new file mode 100644 index 000000000..ce380261e --- /dev/null +++ b/src/lib/google-health/request.ts @@ -0,0 +1,390 @@ +/** + * The Google Health request layer: one data request with the error detail + * read out of a refusal (redacted), the transient retry, the rate-limit wait, + * and the civil-time filters the list endpoint takes. + */ +import { getEvent } from "@/lib/logging/context"; +import { SafeFetchError } from "@/lib/safe-fetch"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { classifyGoogleHealthResponse } from "./response-classifier"; +import { type GoogleHealthDataType } from "./mappers"; +import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; +import { stopRequested } from "./budget"; + +export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; + +const pad2 = (n: number): string => String(n).padStart(2, "0"); + +/** + * Format a UTC instant as the offset-less civil wall clock an observer in `tz` + * reads at that moment (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) — the bound + * format the session `civil_start_time` filter expects. Without `tz` the bound + * forms in UTC. + */ +export function formatCivilBound(instant: Date, tz?: string): string { + if (!tz) return instant.toISOString().slice(0, 19); + const p = wallClockInTz(instant, tz); + return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`; +} + +/** + * The daily-summary `.date` filter prefix style. The official docs contradict + * themselves on this one point: + * - The data-types index states "In a filter parameter … the data type name + * must be in snake case, such as `body_fat`" and its per-type "filter + * parameter" column lists `daily_heart_rate_variability` etc. + * - The `dataPoints.list` reference's ONLY worked daily-summary example is + * `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload + * union key, NOT snake_case. + * Live behaviour: the snake_case daily filter is accepted (HTTP 200) but has + * returned zero rows on accounts whose companion app visibly holds daily HRV / + * RHR — consistent with a vacuously-unmatched predicate. The client therefore + * sends the worked-example camelCase form first and falls back to snake_case + * once if the very first page is rejected with a 400 (see `fetchDataPoints`). + */ +export type GoogleHealthDateFilterStyle = "camel" | "snake"; + +/** + * Build the incremental `filter` field + bound for one list-read data type. + * Centralised so the predicate and the read-time anchor resolution can never + * drift. The legal filter fields are per-shape (anything else 400s): + * - `sample` → `{filter}.sample_time.physical_time` (RFC-3339). + * - `date` → `{key|filter}.date` (`YYYY-MM-DD`) — prefix style per + * `GoogleHealthDateFilterStyle` (docs conflict; camel is the worked + * example, snake the fallback). + * - `sessionEnd` → sleep only: `{filter}.interval.end_time` (RFC-3339) — the + * ONLY filterable time field on sleep; watermark semantics improve too (a + * night is fetched when it ENDS after the cursor). + * - `civilStart` → exercise: `{filter}.interval.civil_start_time` with an + * offset-less civil bound in the user's zone. + * `rollup` types never build a list filter — they read via `:dailyRollUp`. + */ +export function incrementalFilter( + dataType: GoogleHealthDataType, + start: Date, + tz?: string, + dateStyle: GoogleHealthDateFilterStyle = "camel", +): { field: string; bound: string } { + switch (dataType.timeField) { + case "sample": + return { + field: `${dataType.filter}.sample_time.physical_time`, + bound: start.toISOString(), + }; + case "sessionEnd": + return { + field: `${dataType.filter}.interval.end_time`, + bound: start.toISOString(), + }; + case "civilStart": + return { + field: `${dataType.filter}.interval.civil_start_time`, + bound: formatCivilBound(start, tz), + }; + case "date": + return { + field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`, + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates + bound: start.toISOString().slice(0, 10), + }; + case "rollup": + throw new Error( + `Google Health data type ${dataType.path} reads via :dailyRollUp, not dataPoints.list`, + ); + } +} + +/** + * Redact + bound an upstream Google API error message before it reaches an + * error object / wide event: strip bearer tokens, drop query strings from any + * embedded URL (they can carry filters echoing user data or tokens), cap at + * 200 chars. + */ +function redactApiErrorMessage(msg: string): string { + return msg + .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]") + .slice(0, 200); +} + +/** + * Collect the `google.rpc.BadRequest` field violations out of an AIP-193 + * `error.details[]` array as `field: description` fragments. Only the field + * PATH and the description sentence survive — both run through the message + * redactor and a per-fragment cap, and at most three violations are kept, so + * no request value can ride an error string into the logs. + */ +function extractFieldViolations(details: unknown): string[] { + if (!Array.isArray(details)) return []; + const out: string[] = []; + for (const d of details) { + if (!d || typeof d !== "object") continue; + const fv = (d as { fieldViolations?: unknown }).fieldViolations; + if (!Array.isArray(fv)) continue; + for (const v of fv) { + if (out.length >= 3) return out; + if (!v || typeof v !== "object") continue; + const o = v as { field?: unknown; description?: unknown }; + const field = + typeof o.field === "string" + ? redactApiErrorMessage(o.field).slice(0, 80) + : undefined; + const description = + typeof o.description === "string" + ? redactApiErrorMessage(o.description).slice(0, 120) + : undefined; + if (!field && !description) continue; + out.push([field, description].filter(Boolean).join(": ")); + } + } + return out; +} + +/** + * Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`) + * from a non-2xx Google Health body into a short redacted + * `STATUS: message [field: description]` detail string, or undefined when the + * body carries no such envelope. This is what makes a field-grammar 400 + * (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the + * `details[]` BadRequest field violations name the exact offending request + * field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` + * shape instead — handled in `postToken`.) + */ +export function extractGoogleApiErrorDetail(json: unknown): string | undefined { + if (!json || typeof json !== "object") return undefined; + const e = (json as { error?: unknown }).error; + if (!e || typeof e !== "object") return undefined; + const o = e as { status?: unknown; message?: unknown; details?: unknown }; + const status = typeof o.status === "string" ? o.status : undefined; + const message = + typeof o.message === "string" + ? redactApiErrorMessage(o.message) + : undefined; + const violations = extractFieldViolations(o.details); + if (!status && !message && violations.length === 0) return undefined; + const head = [status, message].filter(Boolean).join(": "); + const tail = violations.length > 0 ? `[${violations.join("; ")}]` : ""; + return [head, tail].filter(Boolean).join(" ").slice(0, 500); +} + +// ─── Token source + one data request ─────────────────────────── + +/** + * Where a walk gets its access token. A plain string is used as given. A + * function is asked before every request, so a walk that runs for longer than + * the token lives (an access token lasts an hour; a full-history heart-rate + * walk can take several) picks up the refreshed one instead of carrying an + * expired token into a 401. `forceRefresh` asks for a new token even though + * the current one has not reached its expiry, after Google refused it. + */ +export type GoogleHealthTokenSource = (opts: { + forceRefresh: boolean; +}) => Promise; + +export type GoogleHealthAccessToken = string | GoogleHealthTokenSource; + +/** + * How a data request sits out Google's per-user, per-minute quota (429 + * `RESOURCE_EXHAUSTED`). The quota refills within the minute, so a short wait + * turns a throttled request into a completed one, where failing it used to + * cost the whole collection. The waits follow `Retry-After` (or the + * `google.rpc.RetryInfo` delay in the body) when Google sends one, and + * otherwise double from `baseDelayMs`: 2, 4, 8, 16, 32 s, a minute and a + * bit in all, which spans a full quota window. A requested delay above + * `maxDelayMs` is not slept out; the request fails as the transient it is + * and the next run picks the window up. + */ +export const GOOGLE_HEALTH_RATE_LIMIT = { + maxRetries: 5, + baseDelayMs: 2_000, + maxDelayMs: 60_000, +} as const; + +/** + * The delay Google asked for on a 429, in ms: the `Retry-After` header in + * either of its forms, else a `google.rpc.RetryInfo` detail (`"30s"`) in the + * error body. Undefined when it asked for nothing readable. + */ +export function googleHealthRetryDelayMs( + res: Response, + json: unknown, + now: number = Date.now(), +): number | undefined { + const fromHeader = retryAfterMsOf(res, now); + if (fromHeader !== undefined) return fromHeader; + const details = (json as { error?: { details?: unknown } } | null)?.error + ?.details; + if (Array.isArray(details)) { + for (const d of details) { + const delay = (d as { retryDelay?: unknown } | null)?.retryDelay; + if (typeof delay === "string") { + const m = /^(\d+(?:\.\d+)?)s$/.exec(delay.trim()); + if (m) return Math.round(Number.parseFloat(m[1]!) * 1000); + } + } + } + return undefined; +} + +/** + * How a data request rides out a server that did not answer properly: a + * 500, 502, 503 or 504, a timeout, or a broken connection. A short history + * reads a handful of pages and rarely meets one; a full-history heart-rate + * walk reads thousands, and a single one used to end the walk and throw away + * everything it had read. Three tries more, 2, 4 and 8 s apart (or the + * `Retry-After` a 503 names, up to a minute), before the request fails. + */ +export const GOOGLE_HEALTH_TRANSIENT_RETRY = { + maxRetries: 3, + baseDelayMs: 2_000, +}; + +const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]); + +function waitMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +interface GoogleHealthDataResponse { + status: number; + json: T | null; + verdict: ReturnType; + apiErrorDetail: string | undefined; +} + +/** + * Send one data request and answer with the response it finally got. + * + * Three answers are handled here rather than handed back: + * - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent + * again, up to `maxRetries` times, unless the caller's stop condition + * says to end the walk. + * - a server error or no answer at all (see + * `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count. + * - 401 with a token source: the token expired or was revoked between + * the source's last check and this request. The source is asked once for + * a fresh token and the request is repeated. A second 401, or a 401 on a + * fixed string token, is returned like any other answer. + * + * Every attempt is recorded as its own external call, so the wide event + * shows the throttling and the waits rather than only their outcome. + */ +export async function googleHealthDataRequest( + token: GoogleHealthAccessToken, + label: string, + send: (accessToken: string) => Promise, + expectedFallbackNote: ( + status: number, + apiErrorDetail: string | undefined, + ) => string | undefined = () => undefined, +): Promise> { + let rateLimited = 0; + let transient = 0; + let refreshed = false; + let forceRefresh = false; + for (;;) { + const accessToken = + typeof token === "string" ? token : await token({ forceRefresh }); + forceRefresh = false; + const started = performance.now(); + let res: Response; + try { + res = await send(accessToken); + } catch (err) { + // No answer at all: the request timed out or the connection broke. + // On a walk of a thousand pages one of those is a matter of time, and + // it cost the whole walk. + const retry = + err instanceof SafeFetchError && + (err.kind === "timeout" || err.kind === "network") && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); + const delayMs = + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient; + getEvent()?.addExternalCall({ + service: "google-health", + method: label, + duration_ms: Math.round(performance.now() - started), + ...(retry + ? { note: `no_response_retrying_in_ms=${delayMs}` } + : { error: err instanceof Error ? err.message : String(err) }), + }); + if (!retry) throw err; + transient += 1; + await waitMs(delayMs); + continue; + } + const json = (await res.json().catch(() => null)) as T | null; + const verdict = classifyGoogleHealthResponse(res.status); + const apiErrorDetail = + verdict.classification === "success" + ? undefined + : extractGoogleApiErrorDetail(json); + + const retryAfterMs = + res.status === 429 ? googleHealthRetryDelayMs(res, json) : undefined; + const backoffMs = + res.status === 429 + ? (retryAfterMs ?? + GOOGLE_HEALTH_RATE_LIMIT.baseDelayMs * 2 ** rateLimited) + : 0; + const willWait = + res.status === 429 && + rateLimited < GOOGLE_HEALTH_RATE_LIMIT.maxRetries && + backoffMs <= GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs && + !stopRequested(); + const willRefresh = + res.status === 401 && typeof token !== "string" && !refreshed; + const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) + ? Math.min( + googleHealthRetryDelayMs(res, json) ?? + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient, + GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs, + ) + : 0; + const willRetryTransient = + GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); + + getEvent()?.addExternalCall({ + service: "google-health", + method: label, + duration_ms: Math.round(performance.now() - started), + status: res.status, + ...(expectedFallbackNote(res.status, apiErrorDetail) + ? { note: expectedFallbackNote(res.status, apiErrorDetail) } + : willWait + ? { note: `rate_limited_retrying_in_ms=${backoffMs}` } + : willRefresh + ? { note: "token_rejected_retrying_with_fresh_token" } + : willRetryTransient + ? { note: `server_error_retrying_in_ms=${transientDelayMs}` } + : { + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, + }), + }); + + if (willWait) { + rateLimited += 1; + await waitMs(backoffMs); + continue; + } + if (willRetryTransient) { + transient += 1; + await waitMs(transientDelayMs); + continue; + } + if (willRefresh) { + refreshed = true; + forceRefresh = true; + continue; + } + return { status: res.status, json, verdict, apiErrorDetail }; + } +} diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts index 0c17e2209..075256b44 100644 --- a/src/lib/google-health/sync-activity.ts +++ b/src/lib/google-health/sync-activity.ts @@ -24,18 +24,18 @@ * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection * connected) — the Restricted bundles are granted independently. */ +import { fetchDailyRollUp } from "./client"; +import { mapVo2Max } from "./mappers"; +import { fetchDataPoints } from "./data-points"; import { GOOGLE_HEALTH_DATA_TYPES, type GoogleHealthDataType, type GoogleHealthMappedMeasurement, - fetchDailyRollUp, - fetchDataPoints, mapActiveEnergy, mapDistance, mapFloors, mapSteps, - mapVo2Max, -} from "./client"; +} from "./mappers"; import { getValidToken, googleHealthTokenSource, diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index e32e3b5f9..44f200e28 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -40,6 +40,8 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit import { recordSyncFailure, type FailureKind } from "@/lib/integrations/status"; import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups"; import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write"; +import { type GoogleHealthResumePoint } from "./data-points"; +import { type GoogleHealthTokenSource } from "./request"; import { googleHealthOutcomeTruncated, googleHealthSliceExhausted, @@ -48,9 +50,7 @@ import { noteGoogleHealthMapped, noteGoogleHealthOutcomeFailure, noteGoogleHealthWritten, - type GoogleHealthResumePoint, - type GoogleHealthTokenSource, -} from "./client"; +} from "./budget"; import { GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS, googleHealthTokenDescriptor, diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 8115ac280..bac3fbcb7 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -26,15 +26,17 @@ * can be granted independently in the Google consent flow. */ import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_DEFAULT_MAX_PAGES, GOOGLE_HEALTH_DENSE_MAX_PAGES, + fetchDataPoints, + forEachDataPointPage, +} from "./data-points"; +import { type GoogleHealthTokenSource } from "./request"; +import { + GOOGLE_HEALTH_DATA_TYPES, type GoogleHealthDataPoint, type GoogleHealthDataType, type GoogleHealthMappedMeasurement, - type GoogleHealthTokenSource, - fetchDataPoints, - forEachDataPointPage, googleHealthSampleInstant, mapBloodGlucose, mapBodyFat, @@ -47,7 +49,7 @@ import { mapRestingHeartRate, mapWeight, mapWristTemperature, -} from "./client"; +} from "./mappers"; import { googleHealthBackfillHorizon } from "./backfill-bounds"; import { getValidToken, diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts index b29d40ac1..32ff8dd8e 100644 --- a/src/lib/google-health/sync-sleep.ts +++ b/src/lib/google-health/sync-sleep.ts @@ -22,10 +22,9 @@ */ import { GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, - GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints, - mapSleepSessionDetailed, -} from "./client"; +} from "./data-points"; +import { GOOGLE_HEALTH_DATA_TYPES, mapSleepSessionDetailed } from "./mappers"; import { getValidToken, googleHealthTokenSource, diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts index 24671cf79..22eaabf26 100644 --- a/src/lib/google-health/sync-workout.ts +++ b/src/lib/google-health/sync-workout.ts @@ -18,11 +18,13 @@ */ import { GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, - GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints, +} from "./data-points"; +import { + GOOGLE_HEALTH_DATA_TYPES, mapWorkout, type GoogleHealthMappedWorkout, -} from "./client"; +} from "./mappers"; import { getValidToken, googleHealthTokenSource, diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index b26f32416..02676d189 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -9,7 +9,7 @@ import { runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, type GoogleHealthClientOutcome, -} from "./client"; +} from "./budget"; import { syncUserActivity } from "./sync-activity"; import { syncUserMetrics } from "./sync-metrics"; import { syncUserSleep } from "./sync-sleep"; diff --git a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts index 302bd93d3..aef0db6c5 100644 --- a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts @@ -20,7 +20,8 @@ vi.mock("@/lib/tz/resolver", () => ({ })); import { prisma } from "@/lib/db"; -import { mapSleep, type OuraSleep } from "@/lib/oura/client"; +import { mapSleep } from "@/lib/oura/map"; +import { type OuraSleep } from "@/lib/oura/wire-types"; import { computeSleepScore, reconstructNights, diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 8a8d33ba1..92bc96b99 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -138,11 +138,9 @@ import { LANE_YIELD_DELAY_SECONDS, } from "@/lib/jobs/heavy-lane"; import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys"; -import { - GOOGLE_HEALTH_DATA_TYPES, - forEachDataPointPage, - runWithGoogleHealthClientOutcome, -} from "@/lib/google-health/client"; +import { forEachDataPointPage } from "@/lib/google-health/data-points"; +import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; beforeEach(() => { vi.clearAllMocks(); @@ -479,7 +477,7 @@ describe("runGoogleHealthBackfillForUser — slices", () => { it("queues the next slice past the waiting job's return after giving way", async () => { resumeState.advances = ["fetchHeartRate"]; syncUserGoogleHealthMock.mockResolvedValue({ imported: 10, failed: true }); - const client = await import("@/lib/google-health/client"); + const client = await import("@/lib/google-health/budget"); const spy = vi .spyOn(client, "googleHealthSliceBudget") .mockImplementation(() => ({ @@ -510,7 +508,7 @@ describe("runGoogleHealthBackfillForUser — slices", () => { syncUserGoogleHealthMock.mockResolvedValue({ imported: 10, failed: true }); // The slice ended the way a resumable walk ends it when Google keeps // answering 429 past every wait. - const client = await import("@/lib/google-health/client"); + const client = await import("@/lib/google-health/budget"); const spy = vi .spyOn(client, "googleHealthSliceBudget") .mockImplementation(() => ({ diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 94d44bb9e..e8b816cfb 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -40,7 +40,7 @@ import { googleHealthSliceBudget, type GoogleHealthSliceStopReason, runWithGoogleHealthStop, -} from "@/lib/google-health/client"; +} from "@/lib/google-health/budget"; import { reportJobProgress } from "@/lib/jobs/job-observer"; import { LANE_YIELD_DELAY_SECONDS, laneWanted } from "@/lib/jobs/heavy-lane"; import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys"; diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index e5715afd2..05f49c478 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,13 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - RESILIENCE_LEVELS, - derivePeriodDaysFromCyclePhases, fetchCardiovascularAge, fetchDailyCyclePhases, fetchReadiness, fetchResilience, fetchVo2Max, +} from "../client"; +import { + RESILIENCE_LEVELS, + derivePeriodDaysFromCyclePhases, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -16,6 +18,8 @@ import { mapResilience, mapSleep, mapVo2Max, +} from "@/lib/oura/map"; +import { type OuraCardiovascularAge, type OuraDailyActivity, type OuraDailySleep, @@ -24,7 +28,7 @@ import { type OuraResilience, type OuraSleep, type OuraVo2Max, -} from "../client"; +} from "@/lib/oura/wire-types"; import { OURA_OAUTH_LEGACY_TOKEN_URL, OURA_OAUTH_TOKEN_URL, diff --git a/src/lib/oura/__tests__/map-fixtures.test.ts b/src/lib/oura/__tests__/map-fixtures.test.ts index aa808dbae..0e7ea9954 100644 --- a/src/lib/oura/__tests__/map-fixtures.test.ts +++ b/src/lib/oura/__tests__/map-fixtures.test.ts @@ -29,6 +29,8 @@ import { mapSleep, mapVo2Max, type MappedMeasurement, +} from "@/lib/oura/map"; +import { type OuraCardiovascularAge, type OuraCyclePhase, type OuraDailyActivity, @@ -40,7 +42,7 @@ import { type OuraSleep, type OuraVo2Max, type OuraWorkout, -} from "../client"; +} from "@/lib/oura/wire-types"; import { mapOuraWorkout } from "../sync-workouts"; import cardiovascularAgeFixture from "./__fixtures__/cardiovascular-age.json"; diff --git a/src/lib/oura/__tests__/workouts-heart-rate.test.ts b/src/lib/oura/__tests__/workouts-heart-rate.test.ts index 9d2bd56ea..ba70242bd 100644 --- a/src/lib/oura/__tests__/workouts-heart-rate.test.ts +++ b/src/lib/oura/__tests__/workouts-heart-rate.test.ts @@ -37,7 +37,7 @@ vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); -import { mapHeartRateSample } from "../client"; +import { mapHeartRateSample } from "@/lib/oura/map"; import { OuraApiError } from "../response-classifier"; import { mapOuraSportType } from "../sport-map"; import { diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index c2561df67..4696679bc 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -1,191 +1,35 @@ /** - * Oura Cloud API v2 client: collection reads and mappers. + * Oura Cloud API v2 transport and endpoints: hand-rolled fetch over + * `safeFetch` (no SDK), collection reads with `next_token` pagination, read + * by `start_date` / `end_date` (YYYY-MM-DD). * Docs: https://cloud.ouraring.com/v2/docs (re-verify at build). * - * Hand-rolled fetch over `safeFetch` (no SDK): typed v2 collection fetchers - * with `next_token` pagination and a single source-of-truth field→Measurement - * mapping. Collections are read by `start_date` / `end_date` (YYYY-MM-DD). - * The OAuth handshake is `./oauth.ts`. + * The wire shapes are `./wire-types.ts`, the record → measurement mapping is + * `./map.ts`, the OAuth handshake is `./oauth.ts`. */ import { getEvent } from "@/lib/logging/context"; -import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; -import { dayDiff } from "@/lib/cycle/day-math"; import { safeFetch } from "@/lib/safe-fetch"; import { OuraApiError, classifyOuraResponse } from "./response-classifier"; -import { importValue } from "@/lib/integrations/import-value"; + import { walkPages } from "@/lib/integrations/page-walk"; import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; import { parseOuraMissingScope } from "./scope"; +import type { + OuraCollection, + OuraSleep, + OuraReadiness, + OuraDailySleep, + OuraDailySpo2, + OuraVo2Max, + OuraCardiovascularAge, + OuraResilience, + OuraCyclePhase, + OuraDailyActivity, + OuraWorkout, + OuraHeartRateSample, +} from "./wire-types"; const OURA_API_BASE = "https://api.ouraring.com"; -// ─── Collection reads (v2) ───────────────────────────────────── - -/** v2 paginated envelope: `{ data, next_token }`. */ -export interface OuraCollection { - data: T[]; - next_token?: string | null; -} - -export interface OuraSleep { - id: string; - day: string; - /** - * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a - * detected period the person rejected and `deleted` one they removed; see - * `isRetiredOuraSleepPeriod`. - */ - type?: string | null; - bedtime_start?: string; - bedtime_end?: string; - total_sleep_duration?: number | null; - time_in_bed?: number | null; - efficiency?: number | null; - rem_sleep_duration?: number | null; - deep_sleep_duration?: number | null; - light_sleep_duration?: number | null; - awake_time?: number | null; - average_heart_rate?: number | null; - lowest_heart_rate?: number | null; - average_hrv?: number | null; - average_breath?: number | null; - /** - * 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`. - * Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem, - * `4`=awake. Re-verify the digit→stage mapping against - * `cloud.ouraring.com/v2/docs` at build time (the encoding is published in - * the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api` - * client libraries). Unlike WHOOP — which exposes only stage totals and must - * synthesise an order — Oura gives the real onsets, so we emit timed - * per-segment rows (`reconstructed: false`). - */ - sleep_phase_5_min?: string | null; -} - -/** - * Oura `daily_readiness` contributors — each a 1–100 sub-score that explains - * the headline readiness number. All optional (a record may predate a - * contributor or carry a null for a missing input). - */ -export interface OuraReadinessContributors { - activity_balance?: number | null; - body_temperature?: number | null; - hrv_balance?: number | null; - previous_day_activity?: number | null; - previous_night?: number | null; - recovery_index?: number | null; - resting_heart_rate?: number | null; - sleep_balance?: number | null; -} - -export interface OuraReadiness { - id: string; - day: string; - timestamp?: string; - score?: number | null; - contributors?: OuraReadinessContributors | null; - /** Nightly body-temperature deviation from the personal baseline, °C (signed). */ - temperature_deviation?: number | null; - /** Long-term body-temperature trend deviation, °C (signed). */ - temperature_trend_deviation?: number | null; -} - -/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */ -export interface OuraDailySleep { - id: string; - day: string; - timestamp?: string; - score?: number | null; -} - -/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */ -export interface OuraDailySpo2 { - id: string; - day: string; - spo2_percentage?: { average?: number | null } | null; -} - -/** Oura `vO2_max` — the dedicated cardio-fitness collection, mL/(kg·min). */ -export interface OuraVo2Max { - id: string; - day: string; - timestamp?: string; - vo2_max?: number | null; -} - -/** - * Oura `daily_cardiovascular_age` — Oura's estimate of how the user's - * cardiovascular system is ageing relative to chronological age, in years. - * Maps cleanly onto the existing `VASCULAR_AGE` enum (the same years-unit - * arterial-age concept Withings' Body Scan reports under meastype 155). - */ -export interface OuraCardiovascularAge { - id?: string; - day: string; - /** Estimated vascular age in years. */ - vascular_age?: number | null; -} - -/** - * Oura `daily_resilience` — the daily resilience LEVEL, a categorical band - * describing how well the body copes with cumulative load. The headline is the - * `level` string (limited / adequate / solid / strong / exceptional); the - * collection also carries `contributors` (sleep_recovery / daytime_recovery / - * stress) which we do NOT ingest — we capture only the headline level. - * Re-verify the `level` field name + value set against - * `cloud.ouraring.com/v2/docs` at build time. - */ -export interface OuraResilience { - id?: string; - day: string; - /** limited | adequate | solid | strong | exceptional */ - level?: string | null; -} - -/** - * Oura `daily_cycle_phases` — the per-day Cycle Insights phase estimate - * (temperature-deviation-derived). UNDOCUMENTED in the published OpenAPI - * spec at `cloud.ouraring.com/v2/docs` as of this writing — the route was - * confirmed live against the production API (`GET - * /v2/usercollection/daily_cycle_phases` 400s "Token is missing" identically - * to every documented `daily_*` collection when called unauthenticated, vs a - * genuine 404 for an unknown path) but sits outside the spec dump alongside - * every other Oura reproductive-health resource (`period_start`, - * `pregnancy`, `fertile_window`, `ovulation_confirmed`, `blood_glucose` — all - * listed as valid webhook subscription data types, none with a documented - * read schema). These look gated behind an approval Oura does not extend to - * a self-registered OAuth app by default, so a 403/404 here is the EXPECTED - * outcome for most connections, not an anomaly — see `syncUserOuraCyclePhases` - * in `./cycle-sync` for how that is swallowed without touching the rest of - * the Oura connection's health status. Re-verify the `phase` field name + - * value set against `cloud.ouraring.com/v2/docs` once Oura documents it. - */ -export interface OuraCyclePhase { - id?: string; - day: string; - /** - * Oura's public Cycle Insights copy describes exactly two ring-observable - * phases (`follicular` / `luteal`, temperature-derived); some algorithm - * versions may additionally emit `menstrual` directly. Kept as a loose - * string — an unrecognised / missing value is skipped, never guessed (see - * `derivePeriodDaysFromCyclePhases`). - */ - phase?: string | null; -} - -export interface OuraDailyActivity { - id: string; - day: string; - timestamp?: string; - steps?: number | null; - active_calories?: number | null; - total_calories?: number | null; - /** - * Oura's activity-equivalent distance, metres: what a walk of the day's - * effort would have covered. Not distance travelled; never imported (see - * `mapDailyActivity`). - */ - equivalent_walking_distance?: number | null; -} interface DateRangeQuery { startDate: string; // YYYY-MM-DD @@ -492,28 +336,6 @@ export function fetchDailyCyclePhases( ); } -/** - * Oura `workout` — one activity session, auto-detected or entered. `activity` - * is Oura's own lowercase label (`walking`, `cycling`, `strength_training`, - * ...); `distance` is metres actually covered (a GPS or step-derived figure, - * unlike the daily activity-equivalent distance); `calories` is kcal. - */ -export interface OuraWorkout { - id: string; - day?: string; - activity?: string | null; - calories?: number | null; - distance?: number | null; - start_datetime: string; - end_datetime: string; - /** `easy` | `moderate` | `hard` */ - intensity?: string | null; - /** `manual` | `autodetected` | `confirmed` | `workout_heart_rate` */ - source?: string | null; - /** A name the person gave the workout. Free text; never imported. */ - label?: string | null; -} - export function fetchWorkouts( accessToken: string, query: DateRangeQuery, @@ -526,14 +348,6 @@ export function fetchWorkouts( ); } -/** One `heartrate` sample: a reading at an instant, tagged with its context. */ -export interface OuraHeartRateSample { - bpm?: number | null; - /** `awake` | `rest` | `sleep` | `session` | `live` | `workout` */ - source?: string | null; - timestamp?: string | null; -} - /** * Stream the `heartrate` collection for a time range, one page at a time. * Oura answers it by instant (`start_datetime` / `end_datetime`) and pages @@ -553,537 +367,3 @@ export function forEachHeartRatePage( onPage, ); } - -// ─── Field → Measurement mapping ─────────────────────────────── - -const SEC_TO_MIN = 1 / 60; - -export interface MappedMeasurement { - type: string; - value: number; - unit: string; - measuredAt: Date; - fieldTag: string; - sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE"; - /** - * Per-row externalId override. Most rows derive their externalId from the - * resource prefix + day + fieldTag in the sync layer, but the per-segment - * sleep-timeline rows (and any other row that needs a record-scoped key) - * carry their own here so several rows of one night stay distinct under - * `userId_type_source_externalId`. The sync layer uses `m.externalId ??` - * the default key. - */ - externalId?: string; -} - -function dayAnchor( - day: string, - tz: string | undefined, - bedtimeEnd?: string, -): Date | null { - // Prefer the precise wake instant when present; else anchor the date-only - // day at local noon in the user's zone (canonicalDailyTimestamp), which - // reads the same calendar day there. Noon UTC, the previous anchor, is - // already the next day from UTC+13 on. - if (bedtimeEnd) { - const d = new Date(bedtimeEnd); - return Number.isNaN(d.getTime()) ? null : d; - } - const d = canonicalDailyTimestamp(day, tz); - return Number.isNaN(d.getTime()) ? null : d; -} - -/** - * Map one Oura readiness record: - * - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder - * below WHOOP). - * - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset - * from the personal baseline — illness / luteal-phase / stress signal). - * - * The eight readiness `contributors` are the *why* behind the score; they are - * carried back to the caller separately (not minted as Measurement rows here - * since each is a sub-score, not a stored metric) — see the dropped-field note. - * `temperature_trend_deviation` is the long-term trend twin; we keep only the - * nightly deviation as the actionable signal. - */ -export function mapReadiness( - r: OuraReadiness, - tz?: string, -): MappedMeasurement[] { - const measuredAt = dayAnchor(r.day, tz, r.timestamp); - if (!measuredAt) return []; - const out: MappedMeasurement[] = []; - if (typeof r.score === "number") { - out.push({ - type: "RECOVERY_SCORE", - value: importValue(r.score), - unit: "score", - measuredAt, - fieldTag: "recovery", - }); - } - if (typeof r.temperature_deviation === "number") { - out.push({ - type: "BODY_TEMPERATURE_DEVIATION", - value: importValue(r.temperature_deviation), - unit: "celsius", - measuredAt, - fieldTag: "temp_deviation", - }); - } - return out; -} - -/** - * Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per - * 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake. - * Light maps to our CORE stage (the granular-asleep stage the sleep-night - * reader scores), matching the `light_sleep_duration → CORE` convention the - * stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`. - */ -const HYPNOGRAM_STAGE: Record< - string, - NonNullable -> = { - "1": "DEEP", - "2": "CORE", - "3": "REM", - "4": "AWAKE", -}; - -/** Each hypnogram digit covers 5 minutes. */ -const HYPNOGRAM_INTERVAL_MIN = 5; -const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000; - -/** - * Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured - * `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one - * segment; each segment is stamped at its own END instant (start + duration) - * relative to `bedtime_start`, so the sleep-night reader (which derives - * `start = end − value·60_000`) reconstructs the actual clock-time timeline — - * NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed: - * false` by virtue of OURA being absent from the reader's - * `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the - * hypnogram is absent / unusable so the caller falls back to stage totals. - */ -function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { - const hyp = s.sleep_phase_5_min; - if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) { - return null; - } - const onset = new Date(s.bedtime_start); - if (Number.isNaN(onset.getTime())) return null; - - const out: MappedMeasurement[] = []; - let i = 0; - while (i < hyp.length) { - const digit = hyp[i]; - const stage = HYPNOGRAM_STAGE[digit]; - // Walk the run of identical digits. - let run = 1; - while (i + run < hyp.length && hyp[i + run] === digit) run += 1; - if (stage) { - const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS; - const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS; - // Keyed by the run's own START instant, NOT a positional run index. A - // revised hypnogram re-segments the night (runs merge/split), so an - // index shifted every following run onto the wrong row — wrong - // boundaries updated in place plus phantom tail rows the night-total - // double-counted. A run's start instant is a stable coordinate: an - // unchanged run keeps its id (re-classification overwrites in place), - // and whatever the revision orphaned is swept by the record-scoped - // cleanup in the sync (which also clears legacy `seg:` rows). - const fieldTag = `seg:${new Date(segStartMs).toISOString()}`; - out.push({ - type: "SLEEP_DURATION", - value: importValue(run * HYPNOGRAM_INTERVAL_MIN), - unit: "minutes", - measuredAt: new Date(segEndMs), - fieldTag, - sleepStage: stage, - // Record-scoped so a nap's segments never collide with the main - // sleep's and a re-fetch overwrites in place. - externalId: `sleep:${s.id}:${fieldTag}`, - }); - } - i += run; - } - return out.length > 0 ? out : null; -} - -/** - * Oura sleep periods that are not sleep. Oura's `type` marks a detected period - * the person turned down in the app's confirm prompt as `rest` ("falsely - * detected sleep / nap, rejected by the user") and one they deleted as - * `deleted`; the sleep collection still returns both. Importing them made an - * afternoon on the sofa a second sleep session on that day, which added its - * minutes to the night and pulled the night's midpoint into the afternoon. - * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown - * or missing type stays in, as before. - */ -const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ - "rest", - "deleted", -]); - -/** True for an Oura sleep period the person rejected or deleted. */ -export function isRetiredOuraSleepPeriod(s: Pick): boolean { - return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); -} - -/** - * Map one Oura sleep record. A rejected or deleted period - * (`isRetiredOuraSleepPeriod`) maps to nothing. When the real `sleep_phase_5_min` hypnogram is - * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured - * timeline); otherwise we fall back to the four per-stage totals stamped on the - * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and - * respiratory rate. - */ -export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] { - if (isRetiredOuraSleepPeriod(s)) return []; - const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); - if (!measuredAt) return []; - const out: MappedMeasurement[] = []; - - const timeline = mapSleepTimeline(s); - if (timeline) { - out.push(...timeline); - } else { - // Fallback: no hypnogram — stamp the four stage totals on the sleep END. - const stages: Array< - [number | null | undefined, MappedMeasurement["sleepStage"], string] - > = [ - [s.light_sleep_duration, "CORE", "sleep_core"], - [s.deep_sleep_duration, "DEEP", "sleep_deep"], - [s.rem_sleep_duration, "REM", "sleep_rem"], - [s.awake_time, "AWAKE", "sleep_awake"], - ]; - for (const [sec, stage, fieldTag] of stages) { - if (typeof sec === "number" && sec >= 0) { - out.push({ - type: "SLEEP_DURATION", - value: importValue(sec * SEC_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag, - sleepStage: stage, - // Record-scoped so a nap and the main sleep on the same day don't - // collapse onto one key (B2) — the legacy day-keyed key did. - externalId: `sleep:${s.id}:${fieldTag}`, - }); - } - } - } - // The nightly scalars are record-scoped too (B2): a nap and the main sleep on - // the same day would otherwise overwrite each other's efficiency / HRV / RHR / - // respiratory rate on a shared day key. - if (typeof s.efficiency === "number") { - out.push({ - type: "SLEEP_EFFICIENCY", - value: importValue(s.efficiency), - unit: "%", - measuredAt, - fieldTag: "sleep_eff", - externalId: `sleep:${s.id}:sleep_eff`, - }); - } - if (typeof s.average_hrv === "number" && s.average_hrv > 0) { - out.push({ - type: "HRV_RMSSD", - value: importValue(s.average_hrv), - unit: "ms", - measuredAt, - fieldTag: "hrv_rmssd", - externalId: `sleep:${s.id}:hrv_rmssd`, - }); - } - if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) { - out.push({ - type: "RESTING_HEART_RATE", - value: Math.round(s.lowest_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "rhr", - externalId: `sleep:${s.id}:rhr`, - }); - } - if (typeof s.average_breath === "number" && s.average_breath > 0) { - out.push({ - type: "RESPIRATORY_RATE", - value: importValue(s.average_breath), - unit: "breaths/min", - measuredAt, - fieldTag: "resp_rate", - externalId: `sleep:${s.id}:resp_rate`, - }); - } - return out; -} - -/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */ -export function mapDailySleep( - d: OuraDailySleep, - tz?: string, -): MappedMeasurement[] { - if (typeof d.score !== "number") return []; - const measuredAt = dayAnchor(d.day, tz, d.timestamp); - if (!measuredAt) return []; - return [ - { - type: "SLEEP_SCORE", - value: importValue(d.score), - unit: "score", - measuredAt, - fieldTag: "sleep_score", - }, - ]; -} - -/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */ -export function mapDailySpo2( - s: OuraDailySpo2, - tz?: string, -): MappedMeasurement[] { - const avg = s.spo2_percentage?.average; - if (typeof avg !== "number" || avg <= 0) return []; - // Anchor SpO2 at the day's local noon (no per-record instant in the - // daily_spo2 collection); the recovery / rollup readers key off the local day. - const measuredAt = dayAnchor(s.day, tz); - if (!measuredAt) return []; - return [ - { - type: "OXYGEN_SATURATION", - value: importValue(avg), - unit: "%", - measuredAt, - fieldTag: "spo2", - }, - ]; -} - -/** - * Map one Oura daily-activity record: steps and active energy. - * - * `equivalent_walking_distance` is deliberately not mapped. Oura derives it - * from the day's whole activity (the distance a walk of the same effort would - * have covered), so an hour of cycling or yoga adds kilometres nobody walked. - * Stored as `WALKING_RUNNING_DISTANCE` it read as real distance, and on a day - * no other source covered it became the day's distance. The rows earlier - * syncs wrote under `activity::distance` are retired on their own - * (`./retire-distance`). - */ -export function mapDailyActivity( - a: OuraDailyActivity, - tz?: string, -): MappedMeasurement[] { - const measuredAt = dayAnchor(a.day, tz, a.timestamp); - if (!measuredAt) return []; - const out: MappedMeasurement[] = []; - if (typeof a.steps === "number" && a.steps >= 0) { - out.push({ - type: "ACTIVITY_STEPS", - value: Math.round(a.steps), - unit: "steps", - measuredAt, - fieldTag: "steps", - }); - } - if (typeof a.active_calories === "number" && a.active_calories >= 0) { - out.push({ - type: "ACTIVE_ENERGY_BURNED", - value: importValue(a.active_calories), - unit: "kcal", - measuredAt, - fieldTag: "active_energy", - }); - } - return out; -} - -/** - * Map one Oura `vO2_max` record: the dedicated cardio-fitness collection → - * `VO2_MAX` (mL/(kg·min), the canonical DB unit — no conversion). Skips a - * record with no positive value. - */ -export function mapVo2Max(v: OuraVo2Max, tz?: string): MappedMeasurement[] { - if (typeof v.vo2_max !== "number" || v.vo2_max <= 0) return []; - const measuredAt = dayAnchor(v.day, tz, v.timestamp); - if (!measuredAt) return []; - return [ - { - type: "VO2_MAX", - value: importValue(v.vo2_max), - unit: "mL/(kg·min)", - measuredAt, - fieldTag: "vo2_max", - }, - ]; -} - -/** - * Map one Oura `daily_cardiovascular_age` record → `VASCULAR_AGE` (years, the - * canonical DB unit — no conversion). Skips a record with no positive value. - * Shares the `VASCULAR_AGE` bucket with Withings' Body Scan arterial-age, kept - * distinct from the two vendors only by `source`. - */ -export function mapCardiovascularAge( - c: OuraCardiovascularAge, - tz?: string, -): MappedMeasurement[] { - if (typeof c.vascular_age !== "number" || c.vascular_age <= 0) return []; - const measuredAt = dayAnchor(c.day, tz); - if (!measuredAt) return []; - return [ - { - type: "VASCULAR_AGE", - value: importValue(c.vascular_age), - unit: "years", - measuredAt, - fieldTag: "vascular_age", - }, - ]; -} - -/** - * Single source of truth for the Oura resilience level → ordinal encoding. - * Oura's `daily_resilience.level` is a categorical band; we store it ORDINAL- - * ENCODED in the numeric Measurement `value` so it fits the existing model with - * no new categorical column. Keep in lock-step with the schema comment on the - * `RESILIENCE` enum value and migration 0186. - */ -export const RESILIENCE_LEVELS: Record = { - limited: 1, - adequate: 2, - solid: 3, - strong: 4, - exceptional: 5, -}; - -/** The unit recorded for a RESILIENCE row — the ordinal level scale (1–5). */ -export const RESILIENCE_UNIT = "level" as const; - -/** - * Map one Oura `daily_resilience` record → `RESILIENCE`, ordinal-encoded - * (limited=1 … exceptional=5) into the numeric `value`. An unknown / missing - * level string mints NO row (skipped, never coerced to 0) so a future Oura band - * we do not recognise never lands as a misleading reading. Anchored at the - * day's local noon (the collection carries no per-record instant). - */ -export function mapResilience( - r: OuraResilience, - tz?: string, -): MappedMeasurement[] { - const level = typeof r.level === "string" ? r.level.toLowerCase() : null; - if (!level) return []; - const ordinal = RESILIENCE_LEVELS[level]; - if (typeof ordinal !== "number") return []; - const measuredAt = dayAnchor(r.day, tz); - if (!measuredAt) return []; - return [ - { - type: "RESILIENCE", - value: ordinal, - unit: RESILIENCE_UNIT, - measuredAt, - fieldTag: "resilience", - }, - ]; -} - -/** Heart-rate readings outside this band are not a ring's reading. */ -const OURA_BPM_MIN = 20; -const OURA_BPM_MAX = 250; - -/** - * Map one `heartrate` sample → `PULSE` (bpm), the intraday heart-rate type - * Google Health and Fitbit write their minute stream to. Keyed on the - * sample's own instant (`heartrate:`): Oura emits one reading per - * instant, and a sample is final once it is in the cloud, so a re-read lands - * on the same key. A sample without a parseable instant or with a bpm - * outside a human band maps to nothing. - */ -export function mapHeartRateSample( - sample: OuraHeartRateSample, -): MappedMeasurement | null { - const bpm = sample.bpm; - if (typeof bpm !== "number" || !Number.isFinite(bpm)) return null; - if (bpm < OURA_BPM_MIN || bpm > OURA_BPM_MAX) return null; - if (typeof sample.timestamp !== "string") return null; - const at = new Date(sample.timestamp); - if (Number.isNaN(at.getTime())) return null; - const iso = at.toISOString(); - return { - type: "PULSE", - value: Math.round(bpm), - unit: "bpm", - measuredAt: at, - fieldTag: "hr", - externalId: `heartrate:${iso}`, - }; -} - -// ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ─────────────────── -// -// `daily_cycle_phases` does NOT feed `Measurement` like every collection -// above — it feeds the cycle tracker's `CycleDayLog` via -// `syncUserOuraCyclePhases` in `./cycle-sync`, which owns the write-side -// "manual always wins" guard. This module only derives WHICH days look like -// a period day; it never touches the database. - -const OURA_MENSTRUAL_PHASE = "menstrual"; -const OURA_FOLLICULAR_PHASE = "follicular"; -const OURA_LUTEAL_PHASE = "luteal"; - -/** - * Derive the (sparse) set of `YYYY-MM-DD` days that look like a period day - * from a `daily_cycle_phases` series. The endpoint reports only the PHASE, - * not menstrual flow directly, so a period day is inferred two ways: - * - * 1. A record whose `phase` is literally `"menstrual"` — some algorithm - * versions / accounts may emit this directly. - * 2. The single day a `"luteal"` phase is immediately (calendar-adjacent) - * followed by a `"follicular"` phase. Oura's own definition of the - * follicular phase is "from the first day of menstruation to - * ovulation", so this transition day is the best period-start signal - * the two-phase (follicular/luteal) shape carries. - * - * Records are sorted by `day` internally (the collection's page order is not - * guaranteed to be chronological). Returns bare day strings — the caller - * writes each as a single-day conservative `flow: LIGHT` marker (the same - * "logged but unspecified intensity" boundary the HealthKit `unspecified` - * flow mapping uses), never a multi-day bleed span this endpoint gives no - * evidence for. - */ -export function derivePeriodDaysFromCyclePhases( - records: readonly OuraCyclePhase[], -): string[] { - const sorted = [...records] - .filter((r) => typeof r.day === "string" && r.day.length > 0) - .sort((a, b) => dayDiff(a.day, b.day)); - - const days = new Set(); - let prevPhase: string | null = null; - let prevDay: string | null = null; - - for (const r of sorted) { - const phase = typeof r.phase === "string" ? r.phase.toLowerCase() : null; - - if (phase === OURA_MENSTRUAL_PHASE) { - days.add(r.day); - } else if ( - phase === OURA_FOLLICULAR_PHASE && - prevPhase === OURA_LUTEAL_PHASE && - prevDay !== null && - dayDiff(r.day, prevDay) === 1 - ) { - days.add(r.day); - } - - if (phase) { - prevPhase = phase; - prevDay = r.day; - } - } - - return Array.from(days); -} diff --git a/src/lib/oura/cycle-sync.ts b/src/lib/oura/cycle-sync.ts index bb6d0a5dd..5a8db8876 100644 --- a/src/lib/oura/cycle-sync.ts +++ b/src/lib/oura/cycle-sync.ts @@ -35,10 +35,8 @@ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; -import { - fetchDailyCyclePhases, - derivePeriodDaysFromCyclePhases, -} from "./client"; +import { fetchDailyCyclePhases } from "./client"; +import { derivePeriodDaysFromCyclePhases } from "./map"; /** The conservative "logged but unspecified intensity" flow level — mirrors * the HealthKit `unspecified` → `LIGHT` boundary documented in diff --git a/src/lib/oura/map.ts b/src/lib/oura/map.ts new file mode 100644 index 000000000..d0e5525b8 --- /dev/null +++ b/src/lib/oura/map.ts @@ -0,0 +1,556 @@ +/** + * Oura records to measurement rows: pure functions over the wire shapes + * (`./wire-types.ts`), with no I/O. `__tests__/map-fixtures.test.ts` pins what + * each makes of sanitised fixtures. + */ +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import { dayDiff } from "@/lib/cycle/day-math"; +import { importValue } from "@/lib/integrations/import-value"; +import type { + OuraSleep, + OuraReadiness, + OuraDailySleep, + OuraDailySpo2, + OuraVo2Max, + OuraCardiovascularAge, + OuraResilience, + OuraCyclePhase, + OuraDailyActivity, + OuraHeartRateSample, +} from "./wire-types"; + +const SEC_TO_MIN = 1 / 60; + +export interface MappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + fieldTag: string; + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE"; + /** + * Per-row externalId override. Most rows derive their externalId from the + * resource prefix + day + fieldTag in the sync layer, but the per-segment + * sleep-timeline rows (and any other row that needs a record-scoped key) + * carry their own here so several rows of one night stay distinct under + * `userId_type_source_externalId`. The sync layer uses `m.externalId ??` + * the default key. + */ + externalId?: string; +} + +function dayAnchor( + day: string, + tz: string | undefined, + bedtimeEnd?: string, +): Date | null { + // Prefer the precise wake instant when present; else anchor the date-only + // day at local noon in the user's zone (canonicalDailyTimestamp), which + // reads the same calendar day there. Noon UTC, the previous anchor, is + // already the next day from UTC+13 on. + if (bedtimeEnd) { + const d = new Date(bedtimeEnd); + return Number.isNaN(d.getTime()) ? null : d; + } + const d = canonicalDailyTimestamp(day, tz); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Map one Oura readiness record: + * - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder + * below WHOOP). + * - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset + * from the personal baseline — illness / luteal-phase / stress signal). + * + * The eight readiness `contributors` are the *why* behind the score; they are + * carried back to the caller separately (not minted as Measurement rows here + * since each is a sub-score, not a stored metric) — see the dropped-field note. + * `temperature_trend_deviation` is the long-term trend twin; we keep only the + * nightly deviation as the actionable signal. + */ +export function mapReadiness( + r: OuraReadiness, + tz?: string, +): MappedMeasurement[] { + const measuredAt = dayAnchor(r.day, tz, r.timestamp); + if (!measuredAt) return []; + const out: MappedMeasurement[] = []; + if (typeof r.score === "number") { + out.push({ + type: "RECOVERY_SCORE", + value: importValue(r.score), + unit: "score", + measuredAt, + fieldTag: "recovery", + }); + } + if (typeof r.temperature_deviation === "number") { + out.push({ + type: "BODY_TEMPERATURE_DEVIATION", + value: importValue(r.temperature_deviation), + unit: "celsius", + measuredAt, + fieldTag: "temp_deviation", + }); + } + return out; +} + +/** + * Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per + * 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake. + * Light maps to our CORE stage (the granular-asleep stage the sleep-night + * reader scores), matching the `light_sleep_duration → CORE` convention the + * stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`. + */ +const HYPNOGRAM_STAGE: Record< + string, + NonNullable +> = { + "1": "DEEP", + "2": "CORE", + "3": "REM", + "4": "AWAKE", +}; + +/** Each hypnogram digit covers 5 minutes. */ +const HYPNOGRAM_INTERVAL_MIN = 5; + +const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000; + +/** + * Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured + * `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one + * segment; each segment is stamped at its own END instant (start + duration) + * relative to `bedtime_start`, so the sleep-night reader (which derives + * `start = end − value·60_000`) reconstructs the actual clock-time timeline — + * NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed: + * false` by virtue of OURA being absent from the reader's + * `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the + * hypnogram is absent / unusable so the caller falls back to stage totals. + */ +function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { + const hyp = s.sleep_phase_5_min; + if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) { + return null; + } + const onset = new Date(s.bedtime_start); + if (Number.isNaN(onset.getTime())) return null; + + const out: MappedMeasurement[] = []; + let i = 0; + while (i < hyp.length) { + const digit = hyp[i]; + const stage = HYPNOGRAM_STAGE[digit]; + // Walk the run of identical digits. + let run = 1; + while (i + run < hyp.length && hyp[i + run] === digit) run += 1; + if (stage) { + const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS; + const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS; + // Keyed by the run's own START instant, NOT a positional run index. A + // revised hypnogram re-segments the night (runs merge/split), so an + // index shifted every following run onto the wrong row — wrong + // boundaries updated in place plus phantom tail rows the night-total + // double-counted. A run's start instant is a stable coordinate: an + // unchanged run keeps its id (re-classification overwrites in place), + // and whatever the revision orphaned is swept by the record-scoped + // cleanup in the sync (which also clears legacy `seg:` rows). + const fieldTag = `seg:${new Date(segStartMs).toISOString()}`; + out.push({ + type: "SLEEP_DURATION", + value: importValue(run * HYPNOGRAM_INTERVAL_MIN), + unit: "minutes", + measuredAt: new Date(segEndMs), + fieldTag, + sleepStage: stage, + // Record-scoped so a nap's segments never collide with the main + // sleep's and a re-fetch overwrites in place. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + } + i += run; + } + return out.length > 0 ? out : null; +} + +/** + * Oura sleep periods that are not sleep. Oura's `type` marks a detected period + * the person turned down in the app's confirm prompt as `rest` ("falsely + * detected sleep / nap, rejected by the user") and one they deleted as + * `deleted`; the sleep collection still returns both. Importing them made an + * afternoon on the sofa a second sleep session on that day, which added its + * minutes to the night and pulled the night's midpoint into the afternoon. + * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown + * or missing type stays in, as before. + */ +const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ + "rest", + "deleted", +]); + +/** True for an Oura sleep period the person rejected or deleted. */ +export function isRetiredOuraSleepPeriod(s: Pick): boolean { + return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); +} + +/** + * Map one Oura sleep record. A rejected or deleted period + * (`isRetiredOuraSleepPeriod`) maps to nothing. When the real `sleep_phase_5_min` hypnogram is + * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured + * timeline); otherwise we fall back to the four per-stage totals stamped on the + * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and + * respiratory rate. + */ +export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] { + if (isRetiredOuraSleepPeriod(s)) return []; + const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); + if (!measuredAt) return []; + const out: MappedMeasurement[] = []; + + const timeline = mapSleepTimeline(s); + if (timeline) { + out.push(...timeline); + } else { + // Fallback: no hypnogram — stamp the four stage totals on the sleep END. + const stages: Array< + [number | null | undefined, MappedMeasurement["sleepStage"], string] + > = [ + [s.light_sleep_duration, "CORE", "sleep_core"], + [s.deep_sleep_duration, "DEEP", "sleep_deep"], + [s.rem_sleep_duration, "REM", "sleep_rem"], + [s.awake_time, "AWAKE", "sleep_awake"], + ]; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec === "number" && sec >= 0) { + out.push({ + type: "SLEEP_DURATION", + value: importValue(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag, + sleepStage: stage, + // Record-scoped so a nap and the main sleep on the same day don't + // collapse onto one key (B2) — the legacy day-keyed key did. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + } + } + } + // The nightly scalars are record-scoped too (B2): a nap and the main sleep on + // the same day would otherwise overwrite each other's efficiency / HRV / RHR / + // respiratory rate on a shared day key. + if (typeof s.efficiency === "number") { + out.push({ + type: "SLEEP_EFFICIENCY", + value: importValue(s.efficiency), + unit: "%", + measuredAt, + fieldTag: "sleep_eff", + externalId: `sleep:${s.id}:sleep_eff`, + }); + } + if (typeof s.average_hrv === "number" && s.average_hrv > 0) { + out.push({ + type: "HRV_RMSSD", + value: importValue(s.average_hrv), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + externalId: `sleep:${s.id}:hrv_rmssd`, + }); + } + if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) { + out.push({ + type: "RESTING_HEART_RATE", + value: Math.round(s.lowest_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + externalId: `sleep:${s.id}:rhr`, + }); + } + if (typeof s.average_breath === "number" && s.average_breath > 0) { + out.push({ + type: "RESPIRATORY_RATE", + value: importValue(s.average_breath), + unit: "breaths/min", + measuredAt, + fieldTag: "resp_rate", + externalId: `sleep:${s.id}:resp_rate`, + }); + } + return out; +} + +/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */ +export function mapDailySleep( + d: OuraDailySleep, + tz?: string, +): MappedMeasurement[] { + if (typeof d.score !== "number") return []; + const measuredAt = dayAnchor(d.day, tz, d.timestamp); + if (!measuredAt) return []; + return [ + { + type: "SLEEP_SCORE", + value: importValue(d.score), + unit: "score", + measuredAt, + fieldTag: "sleep_score", + }, + ]; +} + +/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */ +export function mapDailySpo2( + s: OuraDailySpo2, + tz?: string, +): MappedMeasurement[] { + const avg = s.spo2_percentage?.average; + if (typeof avg !== "number" || avg <= 0) return []; + // Anchor SpO2 at the day's local noon (no per-record instant in the + // daily_spo2 collection); the recovery / rollup readers key off the local day. + const measuredAt = dayAnchor(s.day, tz); + if (!measuredAt) return []; + return [ + { + type: "OXYGEN_SATURATION", + value: importValue(avg), + unit: "%", + measuredAt, + fieldTag: "spo2", + }, + ]; +} + +/** + * Map one Oura daily-activity record: steps and active energy. + * + * `equivalent_walking_distance` is deliberately not mapped. Oura derives it + * from the day's whole activity (the distance a walk of the same effort would + * have covered), so an hour of cycling or yoga adds kilometres nobody walked. + * Stored as `WALKING_RUNNING_DISTANCE` it read as real distance, and on a day + * no other source covered it became the day's distance. The rows earlier + * syncs wrote under `activity::distance` are retired on their own + * (`./retire-distance`). + */ +export function mapDailyActivity( + a: OuraDailyActivity, + tz?: string, +): MappedMeasurement[] { + const measuredAt = dayAnchor(a.day, tz, a.timestamp); + if (!measuredAt) return []; + const out: MappedMeasurement[] = []; + if (typeof a.steps === "number" && a.steps >= 0) { + out.push({ + type: "ACTIVITY_STEPS", + value: Math.round(a.steps), + unit: "steps", + measuredAt, + fieldTag: "steps", + }); + } + if (typeof a.active_calories === "number" && a.active_calories >= 0) { + out.push({ + type: "ACTIVE_ENERGY_BURNED", + value: importValue(a.active_calories), + unit: "kcal", + measuredAt, + fieldTag: "active_energy", + }); + } + return out; +} + +/** + * Map one Oura `vO2_max` record: the dedicated cardio-fitness collection → + * `VO2_MAX` (mL/(kg·min), the canonical DB unit — no conversion). Skips a + * record with no positive value. + */ +export function mapVo2Max(v: OuraVo2Max, tz?: string): MappedMeasurement[] { + if (typeof v.vo2_max !== "number" || v.vo2_max <= 0) return []; + const measuredAt = dayAnchor(v.day, tz, v.timestamp); + if (!measuredAt) return []; + return [ + { + type: "VO2_MAX", + value: importValue(v.vo2_max), + unit: "mL/(kg·min)", + measuredAt, + fieldTag: "vo2_max", + }, + ]; +} + +/** + * Map one Oura `daily_cardiovascular_age` record → `VASCULAR_AGE` (years, the + * canonical DB unit — no conversion). Skips a record with no positive value. + * Shares the `VASCULAR_AGE` bucket with Withings' Body Scan arterial-age, kept + * distinct from the two vendors only by `source`. + */ +export function mapCardiovascularAge( + c: OuraCardiovascularAge, + tz?: string, +): MappedMeasurement[] { + if (typeof c.vascular_age !== "number" || c.vascular_age <= 0) return []; + const measuredAt = dayAnchor(c.day, tz); + if (!measuredAt) return []; + return [ + { + type: "VASCULAR_AGE", + value: importValue(c.vascular_age), + unit: "years", + measuredAt, + fieldTag: "vascular_age", + }, + ]; +} + +/** + * Single source of truth for the Oura resilience level → ordinal encoding. + * Oura's `daily_resilience.level` is a categorical band; we store it ORDINAL- + * ENCODED in the numeric Measurement `value` so it fits the existing model with + * no new categorical column. Keep in lock-step with the schema comment on the + * `RESILIENCE` enum value and migration 0186. + */ +export const RESILIENCE_LEVELS: Record = { + limited: 1, + adequate: 2, + solid: 3, + strong: 4, + exceptional: 5, +}; + +/** The unit recorded for a RESILIENCE row — the ordinal level scale (1–5). */ +export const RESILIENCE_UNIT = "level" as const; + +/** + * Map one Oura `daily_resilience` record → `RESILIENCE`, ordinal-encoded + * (limited=1 … exceptional=5) into the numeric `value`. An unknown / missing + * level string mints NO row (skipped, never coerced to 0) so a future Oura band + * we do not recognise never lands as a misleading reading. Anchored at the + * day's local noon (the collection carries no per-record instant). + */ +export function mapResilience( + r: OuraResilience, + tz?: string, +): MappedMeasurement[] { + const level = typeof r.level === "string" ? r.level.toLowerCase() : null; + if (!level) return []; + const ordinal = RESILIENCE_LEVELS[level]; + if (typeof ordinal !== "number") return []; + const measuredAt = dayAnchor(r.day, tz); + if (!measuredAt) return []; + return [ + { + type: "RESILIENCE", + value: ordinal, + unit: RESILIENCE_UNIT, + measuredAt, + fieldTag: "resilience", + }, + ]; +} + +/** Heart-rate readings outside this band are not a ring's reading. */ +const OURA_BPM_MIN = 20; + +const OURA_BPM_MAX = 250; + +/** + * Map one `heartrate` sample → `PULSE` (bpm), the intraday heart-rate type + * Google Health and Fitbit write their minute stream to. Keyed on the + * sample's own instant (`heartrate:`): Oura emits one reading per + * instant, and a sample is final once it is in the cloud, so a re-read lands + * on the same key. A sample without a parseable instant or with a bpm + * outside a human band maps to nothing. + */ +export function mapHeartRateSample( + sample: OuraHeartRateSample, +): MappedMeasurement | null { + const bpm = sample.bpm; + if (typeof bpm !== "number" || !Number.isFinite(bpm)) return null; + if (bpm < OURA_BPM_MIN || bpm > OURA_BPM_MAX) return null; + if (typeof sample.timestamp !== "string") return null; + const at = new Date(sample.timestamp); + if (Number.isNaN(at.getTime())) return null; + const iso = at.toISOString(); + return { + type: "PULSE", + value: Math.round(bpm), + unit: "bpm", + measuredAt: at, + fieldTag: "hr", + externalId: `heartrate:${iso}`, + }; +} + +// ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ─────────────────── +// +// `daily_cycle_phases` does NOT feed `Measurement` like every collection +// above — it feeds the cycle tracker's `CycleDayLog` via +// `syncUserOuraCyclePhases` in `./cycle-sync`, which owns the write-side +// "manual always wins" guard. This module only derives WHICH days look like +// a period day; it never touches the database. + +const OURA_MENSTRUAL_PHASE = "menstrual"; + +const OURA_FOLLICULAR_PHASE = "follicular"; + +const OURA_LUTEAL_PHASE = "luteal"; + +/** + * Derive the (sparse) set of `YYYY-MM-DD` days that look like a period day + * from a `daily_cycle_phases` series. The endpoint reports only the PHASE, + * not menstrual flow directly, so a period day is inferred two ways: + * + * 1. A record whose `phase` is literally `"menstrual"` — some algorithm + * versions / accounts may emit this directly. + * 2. The single day a `"luteal"` phase is immediately (calendar-adjacent) + * followed by a `"follicular"` phase. Oura's own definition of the + * follicular phase is "from the first day of menstruation to + * ovulation", so this transition day is the best period-start signal + * the two-phase (follicular/luteal) shape carries. + * + * Records are sorted by `day` internally (the collection's page order is not + * guaranteed to be chronological). Returns bare day strings — the caller + * writes each as a single-day conservative `flow: LIGHT` marker (the same + * "logged but unspecified intensity" boundary the HealthKit `unspecified` + * flow mapping uses), never a multi-day bleed span this endpoint gives no + * evidence for. + */ +export function derivePeriodDaysFromCyclePhases( + records: readonly OuraCyclePhase[], +): string[] { + const sorted = [...records] + .filter((r) => typeof r.day === "string" && r.day.length > 0) + .sort((a, b) => dayDiff(a.day, b.day)); + + const days = new Set(); + let prevPhase: string | null = null; + let prevDay: string | null = null; + + for (const r of sorted) { + const phase = typeof r.phase === "string" ? r.phase.toLowerCase() : null; + + if (phase === OURA_MENSTRUAL_PHASE) { + days.add(r.day); + } else if ( + phase === OURA_FOLLICULAR_PHASE && + prevPhase === OURA_LUTEAL_PHASE && + prevDay !== null && + dayDiff(r.day, prevDay) === 1 + ) { + days.add(r.day); + } + + if (phase) { + prevPhase = phase; + prevDay = r.day; + } + } + + return Array.from(days); +} diff --git a/src/lib/oura/sync-heart-rate.ts b/src/lib/oura/sync-heart-rate.ts index 48f6d66b3..9c0a9a1f4 100644 --- a/src/lib/oura/sync-heart-rate.ts +++ b/src/lib/oura/sync-heart-rate.ts @@ -21,12 +21,9 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; -import { - forEachHeartRatePage, - mapHeartRateSample, - type MappedMeasurement, - type OuraHeartRateSample, -} from "./client"; +import { forEachHeartRatePage } from "./client"; +import { mapHeartRateSample, type MappedMeasurement } from "./map"; +import { type OuraHeartRateSample } from "./wire-types"; /** Rows per `createMany`: bounded whatever size of page Oura sends. */ export const OURA_HEART_RATE_WRITE_CHUNK = 500; diff --git a/src/lib/oura/sync-workouts.ts b/src/lib/oura/sync-workouts.ts index 0d3ff8def..49bfd0eb2 100644 --- a/src/lib/oura/sync-workouts.ts +++ b/src/lib/oura/sync-workouts.ts @@ -22,7 +22,7 @@ import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; import { annotate, getEvent } from "@/lib/logging/context"; import type { Prisma } from "@/generated/prisma/client"; import type { WorkoutSportType } from "@/lib/validations/workout"; -import type { OuraWorkout } from "./client"; +import type { OuraWorkout } from "./wire-types"; import { mapOuraSportType } from "./sport-map"; export interface OuraWorkoutRow { diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 804b15e06..3f8a901d4 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -69,6 +69,8 @@ import { fetchSleep, fetchVo2Max, fetchWorkouts, +} from "./client"; +import { isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, @@ -79,7 +81,7 @@ import { mapSleep, mapVo2Max, type MappedMeasurement, -} from "./client"; +} from "./map"; import { getOuraConnection, syncOuraScopeDenied } from "./credentials"; import { refreshOuraAfterRefusal } from "./oauth-descriptor"; import { OuraApiError, classifyOuraError } from "./response-classifier"; @@ -883,10 +885,8 @@ export async function upsertOuraMeasurements( } opts.onInserted?.(insertedRows); - // The shared tail: arrivals (unless the caller emits its own), the rollups, - // the measurement read caches back to the oldest reading written (a history - // backfill writes through here and has no other invalidation), and the - // cached assessments of the types. + // The shared tail (a history backfill writes through here and has no other + // invalidation); the caller may emit the arrivals itself. await afterProviderWrite({ userId, provider: "oura", diff --git a/src/lib/oura/wire-types.ts b/src/lib/oura/wire-types.ts new file mode 100644 index 000000000..148790571 --- /dev/null +++ b/src/lib/oura/wire-types.ts @@ -0,0 +1,201 @@ +/** + * The Oura Cloud API v2 wire shapes HealthLog reads: the collection + * envelope and one interface per collection record. Shared by the endpoint + * fetchers (`./client.ts`) and the mappers (`./map.ts`). + */ +/** v2 paginated envelope: `{ data, next_token }`. */ +export interface OuraCollection { + data: T[]; + next_token?: string | null; +} + +export interface OuraSleep { + id: string; + day: string; + /** + * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a + * detected period the person rejected and `deleted` one they removed; see + * `isRetiredOuraSleepPeriod`. + */ + type?: string | null; + bedtime_start?: string; + bedtime_end?: string; + total_sleep_duration?: number | null; + time_in_bed?: number | null; + efficiency?: number | null; + rem_sleep_duration?: number | null; + deep_sleep_duration?: number | null; + light_sleep_duration?: number | null; + awake_time?: number | null; + average_heart_rate?: number | null; + lowest_heart_rate?: number | null; + average_hrv?: number | null; + average_breath?: number | null; + /** + * 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`. + * Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem, + * `4`=awake. Re-verify the digit→stage mapping against + * `cloud.ouraring.com/v2/docs` at build time (the encoding is published in + * the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api` + * client libraries). Unlike WHOOP — which exposes only stage totals and must + * synthesise an order — Oura gives the real onsets, so we emit timed + * per-segment rows (`reconstructed: false`). + */ + sleep_phase_5_min?: string | null; +} + +/** + * Oura `daily_readiness` contributors — each a 1–100 sub-score that explains + * the headline readiness number. All optional (a record may predate a + * contributor or carry a null for a missing input). + */ +export interface OuraReadinessContributors { + activity_balance?: number | null; + body_temperature?: number | null; + hrv_balance?: number | null; + previous_day_activity?: number | null; + previous_night?: number | null; + recovery_index?: number | null; + resting_heart_rate?: number | null; + sleep_balance?: number | null; +} + +export interface OuraReadiness { + id: string; + day: string; + timestamp?: string; + score?: number | null; + contributors?: OuraReadinessContributors | null; + /** Nightly body-temperature deviation from the personal baseline, °C (signed). */ + temperature_deviation?: number | null; + /** Long-term body-temperature trend deviation, °C (signed). */ + temperature_trend_deviation?: number | null; +} + +/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */ +export interface OuraDailySleep { + id: string; + day: string; + timestamp?: string; + score?: number | null; +} + +/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */ +export interface OuraDailySpo2 { + id: string; + day: string; + spo2_percentage?: { average?: number | null } | null; +} + +/** Oura `vO2_max` — the dedicated cardio-fitness collection, mL/(kg·min). */ +export interface OuraVo2Max { + id: string; + day: string; + timestamp?: string; + vo2_max?: number | null; +} + +/** + * Oura `daily_cardiovascular_age` — Oura's estimate of how the user's + * cardiovascular system is ageing relative to chronological age, in years. + * Maps cleanly onto the existing `VASCULAR_AGE` enum (the same years-unit + * arterial-age concept Withings' Body Scan reports under meastype 155). + */ +export interface OuraCardiovascularAge { + id?: string; + day: string; + /** Estimated vascular age in years. */ + vascular_age?: number | null; +} + +/** + * Oura `daily_resilience` — the daily resilience LEVEL, a categorical band + * describing how well the body copes with cumulative load. The headline is the + * `level` string (limited / adequate / solid / strong / exceptional); the + * collection also carries `contributors` (sleep_recovery / daytime_recovery / + * stress) which we do NOT ingest — we capture only the headline level. + * Re-verify the `level` field name + value set against + * `cloud.ouraring.com/v2/docs` at build time. + */ +export interface OuraResilience { + id?: string; + day: string; + /** limited | adequate | solid | strong | exceptional */ + level?: string | null; +} + +/** + * Oura `daily_cycle_phases` — the per-day Cycle Insights phase estimate + * (temperature-deviation-derived). UNDOCUMENTED in the published OpenAPI + * spec at `cloud.ouraring.com/v2/docs` as of this writing — the route was + * confirmed live against the production API (`GET + * /v2/usercollection/daily_cycle_phases` 400s "Token is missing" identically + * to every documented `daily_*` collection when called unauthenticated, vs a + * genuine 404 for an unknown path) but sits outside the spec dump alongside + * every other Oura reproductive-health resource (`period_start`, + * `pregnancy`, `fertile_window`, `ovulation_confirmed`, `blood_glucose` — all + * listed as valid webhook subscription data types, none with a documented + * read schema). These look gated behind an approval Oura does not extend to + * a self-registered OAuth app by default, so a 403/404 here is the EXPECTED + * outcome for most connections, not an anomaly — see `syncUserOuraCyclePhases` + * in `./cycle-sync` for how that is swallowed without touching the rest of + * the Oura connection's health status. Re-verify the `phase` field name + + * value set against `cloud.ouraring.com/v2/docs` once Oura documents it. + */ +export interface OuraCyclePhase { + id?: string; + day: string; + /** + * Oura's public Cycle Insights copy describes exactly two ring-observable + * phases (`follicular` / `luteal`, temperature-derived); some algorithm + * versions may additionally emit `menstrual` directly. Kept as a loose + * string — an unrecognised / missing value is skipped, never guessed (see + * `derivePeriodDaysFromCyclePhases`). + */ + phase?: string | null; +} + +export interface OuraDailyActivity { + id: string; + day: string; + timestamp?: string; + steps?: number | null; + active_calories?: number | null; + total_calories?: number | null; + /** + * Oura's activity-equivalent distance, metres: what a walk of the day's + * effort would have covered. Not distance travelled; never imported (see + * `mapDailyActivity`). + */ + equivalent_walking_distance?: number | null; +} + +/** + * Oura `workout` — one activity session, auto-detected or entered. `activity` + * is Oura's own lowercase label (`walking`, `cycling`, `strength_training`, + * ...); `distance` is metres actually covered (a GPS or step-derived figure, + * unlike the daily activity-equivalent distance); `calories` is kcal. + */ +export interface OuraWorkout { + id: string; + day?: string; + activity?: string | null; + calories?: number | null; + distance?: number | null; + start_datetime: string; + end_datetime: string; + /** `easy` | `moderate` | `hard` */ + intensity?: string | null; + /** `manual` | `autodetected` | `confirmed` | `workout_heart_rate` */ + source?: string | null; + /** A name the person gave the workout. Free text; never imported. */ + label?: string | null; +} + +/** One `heartrate` sample: a reading at an instant, tagged with its context. */ +export interface OuraHeartRateSample { + bpm?: number | null; + /** `awake` | `rest` | `sleep` | `session` | `live` | `workout` */ + source?: string | null; + timestamp?: string | null; +} diff --git a/src/lib/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts index 60e1ee6b7..16962ace1 100644 --- a/src/lib/polar/__tests__/client-exercises.test.ts +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -6,14 +6,14 @@ vi.mock("@/lib/logging/context", () => ({ getEvent: () => null, })); +import { fetchExercises } from "../client"; import { combinePolarStart, - fetchExercises, mapExercise, normaliseExerciseList, parseIsoDuration, - type PolarExercise, -} from "../client"; +} from "@/lib/polar/map"; +import { type PolarExercise } from "@/lib/polar/wire-types"; import { PolarApiError } from "../response-classifier"; function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 99617d6d3..80b331b0a 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -12,18 +12,22 @@ import { fetchNightlyRecharges, fetchSleeps, fetchSpo2, + registerUser, +} from "../client"; +import { mapActivity, mapCardioLoad, mapNightlyRecharge, mapSleep, mapSpo2, - registerUser, +} from "@/lib/polar/map"; +import { type PolarActivity, type PolarCardioLoad, type PolarNightlyRecharge, type PolarSleep, type PolarSpo2, -} from "../client"; +} from "@/lib/polar/wire-types"; import { POLAR_OAUTH_SCOPE, getPolarCredentials } from "@/lib/polar/oauth"; import { PolarApiError } from "../response-classifier"; import { polarOAuth } from "../oauth"; diff --git a/src/lib/polar/__tests__/map-fixtures.test.ts b/src/lib/polar/__tests__/map-fixtures.test.ts index 1cbe10126..c52c3fd59 100644 --- a/src/lib/polar/__tests__/map-fixtures.test.ts +++ b/src/lib/polar/__tests__/map-fixtures.test.ts @@ -24,13 +24,15 @@ import { mapSpo2, normaliseExerciseList, parseIsoDuration, +} from "@/lib/polar/map"; +import { type PolarActivity, type PolarCardioLoad, type PolarExercise, type PolarNightlyRecharge, type PolarSleep, type PolarSpo2, -} from "../client"; +} from "@/lib/polar/wire-types"; import activityFixture from "./__fixtures__/activity.json"; import cardioLoadFixture from "./__fixtures__/cardio-load.json"; import exercisesFixture from "./__fixtures__/exercises.json"; diff --git a/src/lib/polar/__tests__/parity.test.ts b/src/lib/polar/__tests__/parity.test.ts index 4dbae7f36..5c823e7a4 100644 --- a/src/lib/polar/__tests__/parity.test.ts +++ b/src/lib/polar/__tests__/parity.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import { mapExercise, type PolarExercise } from "../client"; +import { mapExercise } from "@/lib/polar/map"; +import { type PolarExercise } from "@/lib/polar/wire-types"; +import { mapActivity as mapStravaActivity } from "@/lib/strava/map"; import { - mapActivity as mapStravaActivity, type StravaSummaryActivity, type StravaDetailedActivity, -} from "@/lib/strava/client"; +} from "@/lib/strava/wire-types"; /** * Sensitive-cohort parity guard (mirrors the google-parity precedent): the SAME diff --git a/src/lib/polar/__tests__/sync-workouts.test.ts b/src/lib/polar/__tests__/sync-workouts.test.ts index 893a4a509..80fb0f3b8 100644 --- a/src/lib/polar/__tests__/sync-workouts.test.ts +++ b/src/lib/polar/__tests__/sync-workouts.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PolarWorkoutRow } from "../client"; +import type { PolarWorkoutRow } from "@/lib/polar/map"; const { createManyAndReturn, diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 4eb151cb9..bd7617701 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -1,12 +1,14 @@ /** - * Polar AccessLink v3 client: user registration, collection reads, mappers. + * Polar AccessLink v3 transport and endpoints: user registration and + * collection reads. * Docs: https://www.polar.com/accesslink-api/ (re-verify at build). * Envelope shapes + windows below verified against the live AccessLink swagger * (`accesslink-api/swagger.yaml`) on 2026-07-24; a body that no longer matches * emits a `polar.envelope.drift` breadcrumb rather than silently reading empty. * - * Hand-rolled fetch over `safeFetch` (no SDK): typed collection fetchers and - * per-resource field→Measurement mappers. The OAuth handshake is `./oauth.ts`. + * Hand-rolled fetch over `safeFetch` (no SDK). The wire shapes are + * `./wire-types.ts`, the record → row mapping is `./map.ts`, the OAuth + * handshake is `./oauth.ts`. * * Polar specifics that differ from WHOOP: * - The token endpoint uses HTTP Basic auth (`base64(client_id:client_secret)`) @@ -22,14 +24,20 @@ * treated as success. */ import { annotate, getEvent } from "@/lib/logging/context"; -import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; + import { safeFetch } from "@/lib/safe-fetch"; -import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; -import type { Prisma } from "@/generated/prisma/client"; -import type { WorkoutSportType } from "@/lib/validations/workout"; + import { PolarApiError, classifyPolarResponse } from "./response-classifier"; -import { mapPolarSportType } from "./sport-map"; -import { importValue } from "@/lib/integrations/import-value"; + +import type { + PolarNightlyRecharge, + PolarSleep, + PolarActivity, + PolarCardioLoad, + PolarSpo2, + PolarExercise, +} from "./wire-types"; +import { EXERCISE_LIST_KEYS, normaliseExerciseList } from "./map"; const POLAR_API_BASE = "https://www.polaraccesslink.com"; @@ -75,66 +83,6 @@ export async function registerUser( } } -// ─── Collection reads ────────────────────────────────────────── - -/** One Polar Nightly Recharge record. */ -export interface PolarNightlyRecharge { - date: string; - nightly_recharge_status?: number | null; - ans_charge?: number | null; - ans_charge_status?: number | null; - heart_rate_avg?: number | null; - heart_rate_variability_avg?: number | null; - breathing_rate_avg?: number | null; -} - -/** One Polar Sleep record. Durations are seconds. */ -export interface PolarSleep { - date: string; - sleep_start_time?: string; - sleep_end_time?: string; - light_sleep?: number | null; - deep_sleep?: number | null; - rem_sleep?: number | null; - total_interruption_duration?: number | null; - sleep_charge?: number | null; - sleep_score?: number | null; - heart_rate_samples?: Record; -} - -/** One current AccessLink daily-activity summary. */ -export interface PolarActivity { - start_time?: string; - active_calories?: number | null; - 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. `test_time` is a Unix - * timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */ -export interface PolarSpo2 { - source_device_id?: string; - test_time?: number; - blood_oxygen_percent?: number | null; -} - interface CollectionResult { records: T[]; } @@ -281,385 +229,6 @@ export function fetchSpo2(accessToken: string): Promise { ); } -// ─── Field → Measurement mapping ─────────────────────────────── - -const SEC_TO_MIN = 1 / 60; - -/** A single mapped reading destined for one `Measurement` row. The sync layer - * 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" | "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; - /** - * The calendar date a date-keyed row belongs to (`YYYY-MM-DD`). The sync - * layer keys the externalId on it rather than on `measuredAt`, which sits at - * the date's local noon in the user's zone and so can fall on a different - * UTC date. - */ - day?: 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 - * 0–100 RECOVERY_SCORE so it feeds the canonical recovery ladder below WHOOP. - * The status is the most stable recovery signal Polar exposes; we linearly - * rescale 1–6 → 0–100 (1 = worst, 6 = best). */ -function rechargeStatusToScore(status: number): number { - const clamped = Math.max(1, Math.min(6, status)); - return importValue(((clamped - 1) / 5) * 100); -} - -/** - * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` - * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the - * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. - * `measuredAt` is the record's date anchored at local noon in the user's - * zone (the wake morning the night belongs to). - */ -export function mapNightlyRecharge( - r: PolarNightlyRecharge, - tz?: string, -): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(r.date, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - const out: MappedMeasurement[] = []; - - if (typeof r.nightly_recharge_status === "number") { - out.push({ - type: "RECOVERY_SCORE", - value: rechargeStatusToScore(r.nightly_recharge_status), - unit: "score", - measuredAt, - fieldTag: "recovery", - }); - } - if ( - typeof r.heart_rate_variability_avg === "number" && - r.heart_rate_variability_avg > 0 - ) { - out.push({ - type: "HRV_RMSSD", - value: importValue(r.heart_rate_variability_avg), - unit: "ms", - measuredAt, - fieldTag: "hrv_rmssd", - }); - } - if (typeof r.heart_rate_avg === "number" && r.heart_rate_avg > 0) { - out.push({ - type: "RESTING_HEART_RATE", - value: Math.round(r.heart_rate_avg), - unit: "bpm", - measuredAt, - fieldTag: "rhr", - }); - } - if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) { - out.push({ - type: "RESPIRATORY_RATE", - value: importValue(r.breathing_rate_avg), - unit: "breaths/min", - measuredAt, - 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: importValue(r.ans_charge), - unit: "score", - measuredAt, - fieldTag: "ans_charge", - }); - } - return out.map((m) => ({ ...m, day: r.date })); -} - -/** - * 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 - * a stage-tagged 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, tz?: string): MappedMeasurement[] { - const out: MappedMeasurement[] = []; - - 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, "CORE" | "DEEP" | "REM" | "AWAKE", string] - > = [ - // AWAKE first — Polar reports the night's total interruption time, but no - // per-stage onset. Lay it as a leading settling-in/awake block so the asleep - // stages partition the remaining window and the night reader can surface a - // real awakeMinutes / efficiency rather than reading the night as fully - // consolidated. Matches WHOOP's leading-AWAKE ordering. - [s.total_interruption_duration, "AWAKE", "sleep_awake"], - [s.light_sleep, "CORE", "sleep_core"], - [s.deep_sleep, "DEEP", "sleep_deep"], - [s.rem_sleep, "REM", "sleep_rem"], - ]; - - if (haveWindow) { - // Reconstructed timeline: lay each 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 the shared builder flags every - // segment reconstructed; the same algorithm backs WHOOP's `mapSleep`. - out.push( - ...reconstructContiguousSleepTimeline({ - startMs, - stages: stages.map(([sec, stage, fieldTag]) => ({ - durationMs: - typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec, - stage, - fieldTag, - })), - // 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]. - inBed: { - durationMs: endMs - startMs, - measuredAt: new Date(endMs), - fieldTag: "sleep_in_bed", - }, - // Stage-tagged externalId keeps the several segment rows of one night - // distinct under userId_type_source_externalId (one segment per stage, - // no positional index — an index renumbered on a re-score when a - // stage's duration flipped 0↔positive, minting fresh ids that - // double-counted the night). - externalIdFor: (fieldTag) => `sleep:${s.date}:seg:${fieldTag}`, - }), - ); - - if (typeof s.sleep_score === "number") { - out.push({ - type: "SLEEP_PERFORMANCE", - value: importValue(s.sleep_score), - unit: "%", - measuredAt: new Date(endMs), - fieldTag: "sleep_score", - }); - } - return out; - } - - // Fallback: no usable window. Anchor at local noon 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 = canonicalDailyTimestamp(s.date, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - for (const [sec, stage, fieldTag] of stages) { - if (typeof sec === "number" && sec >= 0) { - out.push({ - type: "SLEEP_DURATION", - value: importValue(sec * SEC_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag, - sleepStage: stage, - }); - } - } - if (typeof s.sleep_score === "number") { - out.push({ - type: "SLEEP_PERFORMANCE", - value: importValue(s.sleep_score), - unit: "%", - measuredAt, - fieldTag: "sleep_score", - }); - } - return out.map((m) => ({ ...m, day: s.date })); -} - -/** Map one current AccessLink daily-activity record: `steps` + - * `active_calories` where Polar reports them. The active energy portion is - * used instead of total `calories`, which includes BMR. */ -export function mapActivity( - a: PolarActivity, - tz?: string, -): MappedMeasurement[] { - if (typeof a.start_time !== "string") return []; - const day = a.start_time.slice(0, 10); - const measuredAt = canonicalDailyTimestamp(day, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - const out: MappedMeasurement[] = []; - const steps = a.steps; - if (typeof steps === "number" && steps >= 0) { - out.push({ - type: "ACTIVITY_STEPS", - value: Math.round(steps), - unit: "steps", - measuredAt, - fieldTag: "steps", - }); - } - const activeCalories = a.active_calories; - if (typeof activeCalories === "number" && activeCalories >= 0) { - out.push({ - type: "ACTIVE_ENERGY_BURNED", - value: importValue(activeCalories), - unit: "kcal", - measuredAt, - 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: importValue(distance), - unit: "meters", - measuredAt, - fieldTag: "distance", - }); - } - return out.map((m) => ({ ...m, day })); -} - -/** 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, - tz?: string, -): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(c.date, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; - return [ - { - type: "CARDIO_LOAD", - value: importValue(c.cardio_load), - unit: "score", - measuredAt, - fieldTag: "cardio_load", - day: c.date, - }, - ]; -} - -/** Map one Polar SpO2 test result: `blood_oxygen_percent` → - * `OXYGEN_SATURATION` at the test's exact Unix timestamp. */ -export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { - if (typeof r.test_time !== "number") return []; - const measuredAt = new Date(r.test_time * 1000); - if (Number.isNaN(measuredAt.getTime())) return []; - const pct = r.blood_oxygen_percent; - if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; - return [ - { - type: "OXYGEN_SATURATION", - value: importValue(pct), - unit: "%", - measuredAt, - fieldTag: "spo2", - externalId: `spo2:${r.test_time}:${r.source_device_id ?? "unknown"}`, - }, - ]; -} - -// ─── Exercises (workouts) ────────────────────────────────────── - -/** - * One Polar AccessLink exercise (`GET /v3/exercises`) — the stateless list of - * the last 30 days of uploads. The transaction model is DEPRECATED; this GET - * carries NO userId in the path (the user is identified by the Bearer token), - * exactly the shape the v1.31.2 vitals-endpoint fix established. - * - * Field types are conservative on purpose — the live payload's exact per-field - * nullability and the `id` type are not machine-verified, so `id` accepts - * string OR number (coerced to a stable string externalId) and every optional - * numeric is guarded before use. - */ -export interface PolarExercise { - /** Opaque exercise id. Polar's docs leave the type ambiguous; accept either - * and coerce to a stable string externalId (never parsed). */ - id?: string | number | null; - /** ISO-8601 local start time, e.g. `"2026-06-10T07:00:00"`. May or may not - * carry a zone suffix — handled defensively at map time. */ - start_time?: string | null; - /** Minutes east of UTC for `start_time`. Combined with `start_time` to - * recover the true UTC instant. */ - start_time_utc_offset?: number | null; - /** ISO-8601 duration, e.g. `"PT2H44M30S"`. */ - duration?: string | null; - /** Coarse sport enum, e.g. `"RUNNING"`. */ - sport?: string | null; - /** Granular sport enum, e.g. `"TRAIL_RUNNING"`. */ - detailed_sport_info?: string | null; - calories?: number | null; - /** Distance in METRES. */ - distance?: number | null; - heart_rate?: { - average?: number | null; - maximum?: number | null; - } | null; - training_load?: number | null; - device?: string | null; - device_id?: string | null; -} - -/** - * Candidate envelope keys the `/v3/exercises` list might nest under. The - * verified shape (AccessLink swagger, 2026-07-24) is a BARE array covering the - * last 30 days OF UPLOADS, with NO pagination token or page parameter — so the - * bare-array branch is the live path. This tolerant key list is kept purely as - * defense against a future envelope rename; when it engages (an object body - * matching none of these keys), the fetch site logs a `polar.envelope.drift` - * breadcrumb rather than silently reading empty. - */ -const EXERCISE_LIST_KEYS = ["exercises", "data", "items", "records"] as const; - /** * Fetch the user's exercises (`GET /v3/exercises`). Summary only — the * `samples` / `zones` / `route` inline params are deliberately omitted (route / @@ -716,178 +285,3 @@ export async function fetchExercises( } return normaliseExerciseList(json); } - -/** Coerce a `/v3/exercises` body into a `PolarExercise[]`: a bare array, a - * paged envelope (list under one of `EXERCISE_LIST_KEYS`), or `[]` otherwise. */ -export function normaliseExerciseList(json: unknown): PolarExercise[] { - if (!json) return []; - if (Array.isArray(json)) return json as PolarExercise[]; - if (typeof json === "object") { - const obj = json as Record; - for (const key of EXERCISE_LIST_KEYS) { - const list = obj[key]; - if (Array.isArray(list)) return list as PolarExercise[]; - } - } - return []; -} - -// ─── Exercise → Workout mapping ──────────────────────────────── - -/** The `Workout`-row payload a mapped Polar exercise produces. Field names - * mirror `prisma.workout` columns; the sync layer stamps `source = POLAR` + - * `externalId` and upserts on `(userId, source, externalId)`. Mirrors - * `StravaWorkoutRow`. */ -export interface PolarWorkoutRow { - externalId: string; - sportType: WorkoutSportType; - startedAt: Date; - endedAt: Date; - durationSec: number; - totalEnergyKcal: number | null; - totalDistanceM: number | null; - avgHeartRate: number | null; - maxHeartRate: number | null; - elevationM: number | null; - metadata: Prisma.InputJsonValue; -} - -function nonNegInt(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return Math.round(n); -} - -function nonNegFloat(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return n; -} - -/** True when an ISO-8601 datetime string already carries a zone designator - * (`Z` or `±HH:MM` / `±HHMM`). Drives the two branches of start-time - * composition. */ -function hasZoneDesignator(iso: string): boolean { - return /(?:Z|[+-]\d{2}:?\d{2})$/.test(iso.trim()); -} - -/** - * Combine Polar's `start_time` + `start_time_utc_offset` into the true UTC - * instant. - * - * Two cases, because whether `start_time` carries a zone suffix is not verified - * against the live payload: - * - It already has a zone (`…Z` / `…+02:00`): `Date.parse` yields the correct - * instant directly; the offset is NOT applied again. - * - No zone (a naive local wall-clock, the documented shape): interpret the - * wall-clock AS UTC (append `Z`), then subtract the offset minutes (east of - * UTC is positive) to recover the true instant. A missing / non-finite - * offset falls back to 0 (treat the wall-clock as UTC). - * - * Returns null for a missing / unparseable start (the row is skipped, mirroring - * Strava's NaN-start guard). - */ -export function combinePolarStart( - startTime: string | null | undefined, - offsetMinutes: number | null | undefined, -): Date | null { - if (typeof startTime !== "string" || startTime.trim() === "") return null; - const trimmed = startTime.trim(); - if (hasZoneDesignator(trimmed)) { - const ms = Date.parse(trimmed); - return Number.isNaN(ms) ? null : new Date(ms); - } - const asUtcMs = Date.parse(`${trimmed}Z`); - if (Number.isNaN(asUtcMs)) return null; - const offset = - typeof offsetMinutes === "number" && Number.isFinite(offsetMinutes) - ? offsetMinutes - : 0; - return new Date(asUtcMs - offset * 60_000); -} - -const ISO_DURATION_RE = - /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i; - -/** - * Parse an ISO-8601 duration (`PT2H44M30S`, `PT45S`, `P1DT2H`, fractional - * seconds allowed) to whole seconds. Malformed / empty input → 0, mirroring - * Strava's `durationSec ?? 0` fallback so a bad duration never produces a - * negative window. Hand-rolled — no new dependency. - */ -export function parseIsoDuration(raw: string | null | undefined): number { - if (typeof raw !== "string") return 0; - const m = ISO_DURATION_RE.exec(raw.trim()); - if (!m) return 0; - const [, days, hours, minutes, seconds] = m; - const total = - (days ? parseFloat(days) * 86_400 : 0) + - (hours ? parseFloat(hours) * 3_600 : 0) + - (minutes ? parseFloat(minutes) * 60 : 0) + - (seconds ? parseFloat(seconds) : 0); - if (!Number.isFinite(total) || total < 0) return 0; - return Math.round(total); -} - -/** - * Map one Polar exercise into a `Workout` row. `detailed_sport_info` is - * preferred over the coarse `sport`, then mapped through `mapPolarSportType()` - * to a canonical `WorkoutSportType` — the raw Polar labels are never written to - * `sportType` directly (kept in `metadata` for provenance). `startedAt` is the - * UTC instant recovered from `start_time` + `start_time_utc_offset`; `endedAt` - * is `startedAt + durationSec` (Polar exposes no explicit end). `training_load` - * rides `metadata` tied to this row (the WHOOP per-workout-strain precedent), - * never a free-floating Measurement that would survive read-time dedup. - * - * Returns null for an exercise with no id or no parseable start instant - * (nothing to store) — the sync layer skips it, never throws per-row. - */ -export function mapExercise(ex: PolarExercise): PolarWorkoutRow | null { - if (ex.id === null || ex.id === undefined) return null; - const externalId = String(ex.id); - if (externalId.trim() === "") return null; - - const startedAt = combinePolarStart(ex.start_time, ex.start_time_utc_offset); - if (!startedAt) return null; - - const durationSec = parseIsoDuration(ex.duration); - const endedAt = new Date(startedAt.getTime() + durationSec * 1000); - - const sportType = mapPolarSportType(ex.detailed_sport_info, ex.sport); - - const avgHeartRate = nonNegInt(ex.heart_rate?.average); - const maxHeartRate = nonNegInt(ex.heart_rate?.maximum); - - const metadata: Prisma.InputJsonValue = { - // Raw pre-mapping labels, kept for provenance/debugging — never used as the - // canonical `sportType` (see `mapPolarSportType()`). - ...(ex.sport ? { polarSport: ex.sport } : {}), - ...(ex.detailed_sport_info - ? { polarDetailedSportInfo: ex.detailed_sport_info } - : {}), - ...(ex.device ? { polarDevice: ex.device } : {}), - ...(ex.device_id ? { polarDeviceId: ex.device_id } : {}), - ...(typeof ex.training_load === "number" && - Number.isFinite(ex.training_load) - ? { trainingLoad: ex.training_load } - : {}), - ...(typeof ex.start_time_utc_offset === "number" && - Number.isFinite(ex.start_time_utc_offset) - ? { startTimeUtcOffset: ex.start_time_utc_offset } - : {}), - }; - - return { - externalId, - sportType, - startedAt, - endedAt, - durationSec, - totalEnergyKcal: nonNegFloat(ex.calories), - totalDistanceM: nonNegFloat(ex.distance), - avgHeartRate, - maxHeartRate, - // Not on the exercise summary — deriving it would require a GPX fetch - // (out of scope). Left null. - elevationM: null, - metadata, - }; -} diff --git a/src/lib/polar/map.ts b/src/lib/polar/map.ts new file mode 100644 index 000000000..c6cf07303 --- /dev/null +++ b/src/lib/polar/map.ts @@ -0,0 +1,536 @@ +/** + * Polar records to measurement and workout rows: pure functions over the + * wire shapes (`./wire-types.ts`), with no I/O, plus the parsing of an + * exercise list body. `__tests__/map-fixtures.test.ts` pins what each makes of + * sanitised fixtures. + */ +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import type { Prisma } from "@/generated/prisma/client"; +import type { WorkoutSportType } from "@/lib/validations/workout"; +import { mapPolarSportType } from "./sport-map"; +import { importValue } from "@/lib/integrations/import-value"; +import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; +import type { + PolarNightlyRecharge, + PolarSleep, + PolarActivity, + PolarCardioLoad, + PolarSpo2, + PolarExercise, +} from "./wire-types"; + +const SEC_TO_MIN = 1 / 60; + +/** A single mapped reading destined for one `Measurement` row. The sync layer + * 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" | "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; + /** + * The calendar date a date-keyed row belongs to (`YYYY-MM-DD`). The sync + * layer keys the externalId on it rather than on `measuredAt`, which sits at + * the date's local noon in the user's zone and so can fall on a different + * UTC date. + */ + day?: 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 + * 0–100 RECOVERY_SCORE so it feeds the canonical recovery ladder below WHOOP. + * The status is the most stable recovery signal Polar exposes; we linearly + * rescale 1–6 → 0–100 (1 = worst, 6 = best). */ +function rechargeStatusToScore(status: number): number { + const clamped = Math.max(1, Math.min(6, status)); + return importValue(((clamped - 1) / 5) * 100); +} + +/** + * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` + * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the + * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. + * `measuredAt` is the record's date anchored at local noon in the user's + * zone (the wake morning the night belongs to). + */ +export function mapNightlyRecharge( + r: PolarNightlyRecharge, + tz?: string, +): MappedMeasurement[] { + const measuredAt = canonicalDailyTimestamp(r.date, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + const out: MappedMeasurement[] = []; + + if (typeof r.nightly_recharge_status === "number") { + out.push({ + type: "RECOVERY_SCORE", + value: rechargeStatusToScore(r.nightly_recharge_status), + unit: "score", + measuredAt, + fieldTag: "recovery", + }); + } + if ( + typeof r.heart_rate_variability_avg === "number" && + r.heart_rate_variability_avg > 0 + ) { + out.push({ + type: "HRV_RMSSD", + value: importValue(r.heart_rate_variability_avg), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + }); + } + if (typeof r.heart_rate_avg === "number" && r.heart_rate_avg > 0) { + out.push({ + type: "RESTING_HEART_RATE", + value: Math.round(r.heart_rate_avg), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + }); + } + if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) { + out.push({ + type: "RESPIRATORY_RATE", + value: importValue(r.breathing_rate_avg), + unit: "breaths/min", + measuredAt, + 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: importValue(r.ans_charge), + unit: "score", + measuredAt, + fieldTag: "ans_charge", + }); + } + return out.map((m) => ({ ...m, day: r.date })); +} + +/** + * 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 + * a stage-tagged 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, tz?: string): MappedMeasurement[] { + const out: MappedMeasurement[] = []; + + 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, "CORE" | "DEEP" | "REM" | "AWAKE", string] + > = [ + // AWAKE first — Polar reports the night's total interruption time, but no + // per-stage onset. Lay it as a leading settling-in/awake block so the asleep + // stages partition the remaining window and the night reader can surface a + // real awakeMinutes / efficiency rather than reading the night as fully + // consolidated. Matches WHOOP's leading-AWAKE ordering. + [s.total_interruption_duration, "AWAKE", "sleep_awake"], + [s.light_sleep, "CORE", "sleep_core"], + [s.deep_sleep, "DEEP", "sleep_deep"], + [s.rem_sleep, "REM", "sleep_rem"], + ]; + + if (haveWindow) { + // Reconstructed timeline: lay each 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 the shared builder flags every + // segment reconstructed; the same algorithm backs WHOOP's `mapSleep`. + out.push( + ...reconstructContiguousSleepTimeline({ + startMs, + stages: stages.map(([sec, stage, fieldTag]) => ({ + durationMs: + typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec, + stage, + fieldTag, + })), + // 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]. + inBed: { + durationMs: endMs - startMs, + measuredAt: new Date(endMs), + fieldTag: "sleep_in_bed", + }, + // Stage-tagged externalId keeps the several segment rows of one night + // distinct under userId_type_source_externalId (one segment per stage, + // no positional index — an index renumbered on a re-score when a + // stage's duration flipped 0↔positive, minting fresh ids that + // double-counted the night). + externalIdFor: (fieldTag) => `sleep:${s.date}:seg:${fieldTag}`, + }), + ); + + if (typeof s.sleep_score === "number") { + out.push({ + type: "SLEEP_PERFORMANCE", + value: importValue(s.sleep_score), + unit: "%", + measuredAt: new Date(endMs), + fieldTag: "sleep_score", + }); + } + return out; + } + + // Fallback: no usable window. Anchor at local noon 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 = canonicalDailyTimestamp(s.date, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec === "number" && sec >= 0) { + out.push({ + type: "SLEEP_DURATION", + value: importValue(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag, + sleepStage: stage, + }); + } + } + if (typeof s.sleep_score === "number") { + out.push({ + type: "SLEEP_PERFORMANCE", + value: importValue(s.sleep_score), + unit: "%", + measuredAt, + fieldTag: "sleep_score", + }); + } + return out.map((m) => ({ ...m, day: s.date })); +} + +/** Map one current AccessLink daily-activity record: `steps` + + * `active_calories` where Polar reports them. The active energy portion is + * used instead of total `calories`, which includes BMR. */ +export function mapActivity( + a: PolarActivity, + tz?: string, +): MappedMeasurement[] { + if (typeof a.start_time !== "string") return []; + const day = a.start_time.slice(0, 10); + const measuredAt = canonicalDailyTimestamp(day, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + const out: MappedMeasurement[] = []; + const steps = a.steps; + if (typeof steps === "number" && steps >= 0) { + out.push({ + type: "ACTIVITY_STEPS", + value: Math.round(steps), + unit: "steps", + measuredAt, + fieldTag: "steps", + }); + } + const activeCalories = a.active_calories; + if (typeof activeCalories === "number" && activeCalories >= 0) { + out.push({ + type: "ACTIVE_ENERGY_BURNED", + value: importValue(activeCalories), + unit: "kcal", + measuredAt, + 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: importValue(distance), + unit: "meters", + measuredAt, + fieldTag: "distance", + }); + } + return out.map((m) => ({ ...m, day })); +} + +/** 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, + tz?: string, +): MappedMeasurement[] { + const measuredAt = canonicalDailyTimestamp(c.date, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; + return [ + { + type: "CARDIO_LOAD", + value: importValue(c.cardio_load), + unit: "score", + measuredAt, + fieldTag: "cardio_load", + day: c.date, + }, + ]; +} + +/** Map one Polar SpO2 test result: `blood_oxygen_percent` → + * `OXYGEN_SATURATION` at the test's exact Unix timestamp. */ +export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { + if (typeof r.test_time !== "number") return []; + const measuredAt = new Date(r.test_time * 1000); + if (Number.isNaN(measuredAt.getTime())) return []; + const pct = r.blood_oxygen_percent; + if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; + return [ + { + type: "OXYGEN_SATURATION", + value: importValue(pct), + unit: "%", + measuredAt, + fieldTag: "spo2", + externalId: `spo2:${r.test_time}:${r.source_device_id ?? "unknown"}`, + }, + ]; +} + +/** + * Candidate envelope keys the `/v3/exercises` list might nest under. The + * verified shape (AccessLink swagger, 2026-07-24) is a BARE array covering the + * last 30 days OF UPLOADS, with NO pagination token or page parameter — so the + * bare-array branch is the live path. This tolerant key list is kept purely as + * defense against a future envelope rename; when it engages (an object body + * matching none of these keys), the fetch site logs a `polar.envelope.drift` + * breadcrumb rather than silently reading empty. + */ +export const EXERCISE_LIST_KEYS = [ + "exercises", + "data", + "items", + "records", +] as const; + +/** Coerce a `/v3/exercises` body into a `PolarExercise[]`: a bare array, a + * paged envelope (list under one of `EXERCISE_LIST_KEYS`), or `[]` otherwise. */ +export function normaliseExerciseList(json: unknown): PolarExercise[] { + if (!json) return []; + if (Array.isArray(json)) return json as PolarExercise[]; + if (typeof json === "object") { + const obj = json as Record; + for (const key of EXERCISE_LIST_KEYS) { + const list = obj[key]; + if (Array.isArray(list)) return list as PolarExercise[]; + } + } + return []; +} + +// ─── Exercise → Workout mapping ──────────────────────────────── + +/** The `Workout`-row payload a mapped Polar exercise produces. Field names + * mirror `prisma.workout` columns; the sync layer stamps `source = POLAR` + + * `externalId` and upserts on `(userId, source, externalId)`. Mirrors + * `StravaWorkoutRow`. */ +export interface PolarWorkoutRow { + externalId: string; + sportType: WorkoutSportType; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + elevationM: number | null; + metadata: Prisma.InputJsonValue; +} + +function nonNegInt(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return Math.round(n); +} + +function nonNegFloat(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return n; +} + +/** True when an ISO-8601 datetime string already carries a zone designator + * (`Z` or `±HH:MM` / `±HHMM`). Drives the two branches of start-time + * composition. */ +function hasZoneDesignator(iso: string): boolean { + return /(?:Z|[+-]\d{2}:?\d{2})$/.test(iso.trim()); +} + +/** + * Combine Polar's `start_time` + `start_time_utc_offset` into the true UTC + * instant. + * + * Two cases, because whether `start_time` carries a zone suffix is not verified + * against the live payload: + * - It already has a zone (`…Z` / `…+02:00`): `Date.parse` yields the correct + * instant directly; the offset is NOT applied again. + * - No zone (a naive local wall-clock, the documented shape): interpret the + * wall-clock AS UTC (append `Z`), then subtract the offset minutes (east of + * UTC is positive) to recover the true instant. A missing / non-finite + * offset falls back to 0 (treat the wall-clock as UTC). + * + * Returns null for a missing / unparseable start (the row is skipped, mirroring + * Strava's NaN-start guard). + */ +export function combinePolarStart( + startTime: string | null | undefined, + offsetMinutes: number | null | undefined, +): Date | null { + if (typeof startTime !== "string" || startTime.trim() === "") return null; + const trimmed = startTime.trim(); + if (hasZoneDesignator(trimmed)) { + const ms = Date.parse(trimmed); + return Number.isNaN(ms) ? null : new Date(ms); + } + const asUtcMs = Date.parse(`${trimmed}Z`); + if (Number.isNaN(asUtcMs)) return null; + const offset = + typeof offsetMinutes === "number" && Number.isFinite(offsetMinutes) + ? offsetMinutes + : 0; + return new Date(asUtcMs - offset * 60_000); +} + +const ISO_DURATION_RE = + /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i; + +/** + * Parse an ISO-8601 duration (`PT2H44M30S`, `PT45S`, `P1DT2H`, fractional + * seconds allowed) to whole seconds. Malformed / empty input → 0, mirroring + * Strava's `durationSec ?? 0` fallback so a bad duration never produces a + * negative window. Hand-rolled — no new dependency. + */ +export function parseIsoDuration(raw: string | null | undefined): number { + if (typeof raw !== "string") return 0; + const m = ISO_DURATION_RE.exec(raw.trim()); + if (!m) return 0; + const [, days, hours, minutes, seconds] = m; + const total = + (days ? parseFloat(days) * 86_400 : 0) + + (hours ? parseFloat(hours) * 3_600 : 0) + + (minutes ? parseFloat(minutes) * 60 : 0) + + (seconds ? parseFloat(seconds) : 0); + if (!Number.isFinite(total) || total < 0) return 0; + return Math.round(total); +} + +/** + * Map one Polar exercise into a `Workout` row. `detailed_sport_info` is + * preferred over the coarse `sport`, then mapped through `mapPolarSportType()` + * to a canonical `WorkoutSportType` — the raw Polar labels are never written to + * `sportType` directly (kept in `metadata` for provenance). `startedAt` is the + * UTC instant recovered from `start_time` + `start_time_utc_offset`; `endedAt` + * is `startedAt + durationSec` (Polar exposes no explicit end). `training_load` + * rides `metadata` tied to this row (the WHOOP per-workout-strain precedent), + * never a free-floating Measurement that would survive read-time dedup. + * + * Returns null for an exercise with no id or no parseable start instant + * (nothing to store) — the sync layer skips it, never throws per-row. + */ +export function mapExercise(ex: PolarExercise): PolarWorkoutRow | null { + if (ex.id === null || ex.id === undefined) return null; + const externalId = String(ex.id); + if (externalId.trim() === "") return null; + + const startedAt = combinePolarStart(ex.start_time, ex.start_time_utc_offset); + if (!startedAt) return null; + + const durationSec = parseIsoDuration(ex.duration); + const endedAt = new Date(startedAt.getTime() + durationSec * 1000); + + const sportType = mapPolarSportType(ex.detailed_sport_info, ex.sport); + + const avgHeartRate = nonNegInt(ex.heart_rate?.average); + const maxHeartRate = nonNegInt(ex.heart_rate?.maximum); + + const metadata: Prisma.InputJsonValue = { + // Raw pre-mapping labels, kept for provenance/debugging — never used as the + // canonical `sportType` (see `mapPolarSportType()`). + ...(ex.sport ? { polarSport: ex.sport } : {}), + ...(ex.detailed_sport_info + ? { polarDetailedSportInfo: ex.detailed_sport_info } + : {}), + ...(ex.device ? { polarDevice: ex.device } : {}), + ...(ex.device_id ? { polarDeviceId: ex.device_id } : {}), + ...(typeof ex.training_load === "number" && + Number.isFinite(ex.training_load) + ? { trainingLoad: ex.training_load } + : {}), + ...(typeof ex.start_time_utc_offset === "number" && + Number.isFinite(ex.start_time_utc_offset) + ? { startTimeUtcOffset: ex.start_time_utc_offset } + : {}), + }; + + return { + externalId, + sportType, + startedAt, + endedAt, + durationSec, + totalEnergyKcal: nonNegFloat(ex.calories), + totalDistanceM: nonNegFloat(ex.distance), + avgHeartRate, + maxHeartRate, + // Not on the exercise summary — deriving it would require a GPX fetch + // (out of scope). Left null. + elevationM: null, + metadata, + }; +} diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts index 59e15cfec..e92035790 100644 --- a/src/lib/polar/sync-workouts.ts +++ b/src/lib/polar/sync-workouts.ts @@ -39,7 +39,8 @@ import { recordSyncSuccess, toFailureKind, } from "@/lib/integrations/status"; -import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client"; +import { fetchExercises } from "./client"; +import { mapExercise, type PolarWorkoutRow } from "./map"; import { getPolarConnection } from "./credentials"; import { PolarApiError, diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index f48aafcb1..10c6976e8 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -68,13 +68,15 @@ import { fetchNightlyRecharges, fetchSleeps, fetchSpo2, +} from "./client"; +import { mapActivity, mapCardioLoad, mapNightlyRecharge, mapSleep, mapSpo2, type MappedMeasurement, -} from "./client"; +} from "./map"; import { sweepStaleSleepSegments, type SleepSegmentSweep, diff --git a/src/lib/polar/wire-types.ts b/src/lib/polar/wire-types.ts new file mode 100644 index 000000000..860371375 --- /dev/null +++ b/src/lib/polar/wire-types.ts @@ -0,0 +1,103 @@ +/** + * The Polar AccessLink v3 wire shapes HealthLog reads, one interface per + * collection record. Shared by the endpoint fetchers (`./client.ts`) and the + * mappers (`./map.ts`). + */ +/** One Polar Nightly Recharge record. */ +export interface PolarNightlyRecharge { + date: string; + nightly_recharge_status?: number | null; + ans_charge?: number | null; + ans_charge_status?: number | null; + heart_rate_avg?: number | null; + heart_rate_variability_avg?: number | null; + breathing_rate_avg?: number | null; +} + +/** One Polar Sleep record. Durations are seconds. */ +export interface PolarSleep { + date: string; + sleep_start_time?: string; + sleep_end_time?: string; + light_sleep?: number | null; + deep_sleep?: number | null; + rem_sleep?: number | null; + total_interruption_duration?: number | null; + sleep_charge?: number | null; + sleep_score?: number | null; + heart_rate_samples?: Record; +} + +/** One current AccessLink daily-activity summary. */ +export interface PolarActivity { + start_time?: string; + active_calories?: number | null; + 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. `test_time` is a Unix + * timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */ +export interface PolarSpo2 { + source_device_id?: string; + test_time?: number; + blood_oxygen_percent?: number | null; +} + +// ─── Exercises (workouts) ────────────────────────────────────── + +/** + * One Polar AccessLink exercise (`GET /v3/exercises`) — the stateless list of + * the last 30 days of uploads. The transaction model is DEPRECATED; this GET + * carries NO userId in the path (the user is identified by the Bearer token), + * exactly the shape the v1.31.2 vitals-endpoint fix established. + * + * Field types are conservative on purpose — the live payload's exact per-field + * nullability and the `id` type are not machine-verified, so `id` accepts + * string OR number (coerced to a stable string externalId) and every optional + * numeric is guarded before use. + */ +export interface PolarExercise { + /** Opaque exercise id. Polar's docs leave the type ambiguous; accept either + * and coerce to a stable string externalId (never parsed). */ + id?: string | number | null; + /** ISO-8601 local start time, e.g. `"2026-06-10T07:00:00"`. May or may not + * carry a zone suffix — handled defensively at map time. */ + start_time?: string | null; + /** Minutes east of UTC for `start_time`. Combined with `start_time` to + * recover the true UTC instant. */ + start_time_utc_offset?: number | null; + /** ISO-8601 duration, e.g. `"PT2H44M30S"`. */ + duration?: string | null; + /** Coarse sport enum, e.g. `"RUNNING"`. */ + sport?: string | null; + /** Granular sport enum, e.g. `"TRAIL_RUNNING"`. */ + detailed_sport_info?: string | null; + calories?: number | null; + /** Distance in METRES. */ + distance?: number | null; + heart_rate?: { + average?: number | null; + maximum?: number | null; + } | null; + training_load?: number | null; + device?: string | null; + device_id?: string | null; +} diff --git a/src/lib/strava/__tests__/client.test.ts b/src/lib/strava/__tests__/client.test.ts index a6a470720..e9c944d3f 100644 --- a/src/lib/strava/__tests__/client.test.ts +++ b/src/lib/strava/__tests__/client.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it } from "vitest"; +import { mapActivity, summaryHasHeartRate } from "@/lib/strava/map"; import { - mapActivity, - summaryHasHeartRate, type StravaSummaryActivity, type StravaDetailedActivity, -} from "../client"; +} from "@/lib/strava/wire-types"; import { classifyStravaResponse } from "../response-classifier"; const baseSummary: StravaSummaryActivity = { diff --git a/src/lib/strava/__tests__/map-fixtures.test.ts b/src/lib/strava/__tests__/map-fixtures.test.ts index 954ba650b..b5624ef29 100644 --- a/src/lib/strava/__tests__/map-fixtures.test.ts +++ b/src/lib/strava/__tests__/map-fixtures.test.ts @@ -15,13 +15,12 @@ import { format } from "prettier"; import { describe, expect, it } from "vitest"; +import { stravaRateLimitRetryAfterSeconds } from "../client"; +import { mapActivity, summaryHasHeartRate } from "@/lib/strava/map"; import { - mapActivity, - stravaRateLimitRetryAfterSeconds, - summaryHasHeartRate, type StravaDetailedActivity, type StravaSummaryActivity, -} from "../client"; +} from "@/lib/strava/wire-types"; import { mapStravaSportType } from "../sport-map"; import activitiesFixture from "./__fixtures__/activities.json"; diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 6b5fd33a8..cc5ed7249 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -1,10 +1,10 @@ /** - * Strava API v3 client: activity reads. + * Strava API v3 transport and endpoints: activity reads. * Docs: https://developers.strava.com/docs/reference/ (re-verify at build). * - * Hand-rolled fetch over `safeFetch` (no vendor SDK): typed collection - * fetchers and a single field → `Workout` mapper. The OAuth handshake is - * `./oauth.ts`. + * Hand-rolled fetch over `safeFetch` (no vendor SDK). The wire shapes are + * `./wire-types.ts`, the activity → workout mapping is `./map.ts`, the OAuth + * handshake is `./oauth.ts`. * * Strava specifics: * - It is a WORKOUT source only — the API is activity-centric and exposes no @@ -23,48 +23,16 @@ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after"; -import type { Prisma } from "@/generated/prisma/client"; + import { StravaApiError, classifyStravaResponse } from "./response-classifier"; -import { mapStravaSportType } from "./sport-map"; -import type { WorkoutSportType } from "@/lib/validations/workout"; + +import type { + StravaSummaryActivity, + StravaDetailedActivity, +} from "./wire-types"; const STRAVA_API_BASE = "https://www.strava.com/api/v3"; -// ─── Activity reads (v3) ─────────────────────────────────────── - -/** Strava `SummaryActivity` — the list-endpoint shape. Distances are metres, - * durations seconds, `start_date` a UTC ISO-8601 instant. HR / power fields are - * present when the activity recorded them; `calories` is NOT (detail only). */ -export interface StravaSummaryActivity { - id: number; - name?: string | null; - distance?: number | null; - moving_time?: number | null; - elapsed_time?: number | null; - total_elevation_gain?: number | null; - type?: string | null; - sport_type?: string | null; - start_date?: string | null; - start_date_local?: string | null; - average_heartrate?: number | null; - max_heartrate?: number | null; - has_heartrate?: boolean | null; - average_watts?: number | null; - weighted_average_watts?: number | null; - device_watts?: boolean | null; - average_cadence?: number | null; - trainer?: boolean | null; - commute?: boolean | null; - gear_id?: string | null; -} - -/** Strava `DetailedActivity` — the per-activity shape. Superset of the summary - * that additionally carries `calories` (kcal). */ -export interface StravaDetailedActivity extends StravaSummaryActivity { - calories?: number | null; - description?: string | null; -} - export interface FetchActivitiesQuery { /** Epoch SECONDS cursor — return activities started after this instant. */ after?: number; @@ -183,145 +151,3 @@ export function fetchActivityById( "fetchActivityById", ); } - -// ─── Activity → Workout mapping ──────────────────────────────── - -/** The `Workout`-row payload a mapped Strava activity produces. Field names - * mirror `prisma.workout` columns; the sync layer stamps `source = STRAVA` + - * `externalId` and upserts on `(userId, source, externalId)`. */ -export interface StravaWorkoutRow { - externalId: string; - sportType: WorkoutSportType; - startedAt: Date; - endedAt: Date; - durationSec: number; - totalEnergyKcal: number | null; - totalDistanceM: number | null; - avgHeartRate: number | null; - maxHeartRate: number | null; - elevationM: number | null; - metadata: Prisma.InputJsonValue; - /** - * True when the row was mapped from the summary alone. The calories, the - * description and (when the summary lacks it) the heart rate exist only on - * the detail read, so an update from such a row must not write their - * absence over what an earlier detail read stored. - */ - summaryOnly?: boolean; -} - -function nonNegInt(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return Math.round(n); -} - -function nonNegFloat(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return n; -} - -/** - * Map one Strava activity into a `Workout` row. `sport_type` is preferred over - * the legacy `type`, then mapped through `mapStravaSportType()` to a canonical - * `WorkoutSportType` — the raw Strava label is never written to `sportType` - * directly (see `src/lib/strava/sport-map.ts` for why). The raw label is kept - * verbatim in `metadata.stravaType` for provenance/debugging. Duration uses - * `moving_time` (active duration) with `elapsed_time` kept in `metadata`. HR / - * calories come from the summary when present, else from the optional - * `DetailedActivity`. - * - * The activity `name` / `description` are the user's own free-text DATA — they - * are stored in `metadata` only and NEVER interpolated into any prompt as an - * instruction (the MCP/coach free-text rule). Power / cadence / commute flags - * ride `metadata` for provenance but are NOT surfaced as training analytics. - * Returns null for an activity with no id / start instant (nothing to store). - */ -export function mapActivity( - summary: StravaSummaryActivity, - detail?: StravaDetailedActivity | null, -): StravaWorkoutRow | null { - if (typeof summary.id !== "number") return null; - const startIso = summary.start_date ?? detail?.start_date ?? null; - if (!startIso) return null; - const startedAt = new Date(startIso); - if (Number.isNaN(startedAt.getTime())) return null; - - const elapsedSec = nonNegInt(summary.elapsed_time ?? detail?.elapsed_time); - const movingSec = nonNegInt(summary.moving_time ?? detail?.moving_time); - const durationSec = movingSec ?? elapsedSec ?? 0; - // The clock window is elapsed time (moving time excludes pauses); fall back to - // moving time when elapsed is absent so `endedAt` is never before `startedAt`. - const endedAt = new Date( - startedAt.getTime() + (elapsedSec ?? durationSec) * 1000, - ); - - const avgHeartRate = nonNegInt( - summary.average_heartrate ?? detail?.average_heartrate, - ); - const maxHeartRate = nonNegInt( - summary.max_heartrate ?? detail?.max_heartrate, - ); - const calories = nonNegFloat(detail?.calories); - - const rawSportType = - summary.sport_type ?? summary.type ?? detail?.sport_type ?? "Workout"; - const sportType = mapStravaSportType(rawSportType); - - const metadata: Prisma.InputJsonValue = { - // Activity name/description are user free-text → DATA, never instructions. - ...(summary.name ? { stravaName: summary.name } : {}), - ...(detail?.description ? { stravaDescription: detail.description } : {}), - ...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}), - // Raw pre-mapping label, kept for provenance/debugging — never used as - // the canonical `sportType` itself (see `mapStravaSportType()`). - ...(rawSportType ? { stravaType: rawSportType } : {}), - ...(typeof summary.average_watts === "number" - ? { averageWatts: summary.average_watts } - : {}), - ...(typeof summary.weighted_average_watts === "number" - ? { weightedAverageWatts: summary.weighted_average_watts } - : {}), - ...(typeof summary.device_watts === "boolean" - ? { deviceWatts: summary.device_watts } - : {}), - ...(typeof summary.average_cadence === "number" - ? { averageCadence: summary.average_cadence } - : {}), - ...(typeof summary.trainer === "boolean" - ? { trainer: summary.trainer } - : {}), - ...(typeof summary.commute === "boolean" - ? { commute: summary.commute } - : {}), - ...(summary.gear_id ? { gearId: summary.gear_id } : {}), - }; - - return { - externalId: String(summary.id), - sportType, - startedAt, - endedAt, - durationSec, - totalEnergyKcal: calories, - totalDistanceM: nonNegFloat(summary.distance ?? detail?.distance), - avgHeartRate, - maxHeartRate, - elevationM: nonNegFloat( - summary.total_elevation_gain ?? detail?.total_elevation_gain, - ), - metadata, - summaryOnly: detail == null, - }; -} - -/** True when the summary already carries HR + calories are irrelevant — i.e. - * the summary alone is enough and a detail fetch can be skipped. Strava never - * puts `calories` on the summary, so a detail fetch is what fills it; callers - * use this only to decide whether HR is already covered. */ -export function summaryHasHeartRate(a: StravaSummaryActivity): boolean { - return ( - a.has_heartrate === true || - typeof a.average_heartrate === "number" || - typeof a.max_heartrate === "number" - ); -} diff --git a/src/lib/strava/map.ts b/src/lib/strava/map.ts new file mode 100644 index 000000000..6ef96a36a --- /dev/null +++ b/src/lib/strava/map.ts @@ -0,0 +1,152 @@ +/** + * Strava activities to workout rows: pure functions over the wire shapes + * (`./wire-types.ts`), with no I/O. `__tests__/map-fixtures.test.ts` pins + * what they make of sanitised fixtures. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { mapStravaSportType } from "./sport-map"; +import type { WorkoutSportType } from "@/lib/validations/workout"; +import type { + StravaSummaryActivity, + StravaDetailedActivity, +} from "./wire-types"; + +/** The `Workout`-row payload a mapped Strava activity produces. Field names + * mirror `prisma.workout` columns; the sync layer stamps `source = STRAVA` + + * `externalId` and upserts on `(userId, source, externalId)`. */ +export interface StravaWorkoutRow { + externalId: string; + sportType: WorkoutSportType; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + elevationM: number | null; + metadata: Prisma.InputJsonValue; + /** + * True when the row was mapped from the summary alone. The calories, the + * description and (when the summary lacks it) the heart rate exist only on + * the detail read, so an update from such a row must not write their + * absence over what an earlier detail read stored. + */ + summaryOnly?: boolean; +} + +function nonNegInt(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return Math.round(n); +} + +function nonNegFloat(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return n; +} + +/** + * Map one Strava activity into a `Workout` row. `sport_type` is preferred over + * the legacy `type`, then mapped through `mapStravaSportType()` to a canonical + * `WorkoutSportType` — the raw Strava label is never written to `sportType` + * directly (see `src/lib/strava/sport-map.ts` for why). The raw label is kept + * verbatim in `metadata.stravaType` for provenance/debugging. Duration uses + * `moving_time` (active duration) with `elapsed_time` kept in `metadata`. HR / + * calories come from the summary when present, else from the optional + * `DetailedActivity`. + * + * The activity `name` / `description` are the user's own free-text DATA — they + * are stored in `metadata` only and NEVER interpolated into any prompt as an + * instruction (the MCP/coach free-text rule). Power / cadence / commute flags + * ride `metadata` for provenance but are NOT surfaced as training analytics. + * Returns null for an activity with no id / start instant (nothing to store). + */ +export function mapActivity( + summary: StravaSummaryActivity, + detail?: StravaDetailedActivity | null, +): StravaWorkoutRow | null { + if (typeof summary.id !== "number") return null; + const startIso = summary.start_date ?? detail?.start_date ?? null; + if (!startIso) return null; + const startedAt = new Date(startIso); + if (Number.isNaN(startedAt.getTime())) return null; + + const elapsedSec = nonNegInt(summary.elapsed_time ?? detail?.elapsed_time); + const movingSec = nonNegInt(summary.moving_time ?? detail?.moving_time); + const durationSec = movingSec ?? elapsedSec ?? 0; + // The clock window is elapsed time (moving time excludes pauses); fall back to + // moving time when elapsed is absent so `endedAt` is never before `startedAt`. + const endedAt = new Date( + startedAt.getTime() + (elapsedSec ?? durationSec) * 1000, + ); + + const avgHeartRate = nonNegInt( + summary.average_heartrate ?? detail?.average_heartrate, + ); + const maxHeartRate = nonNegInt( + summary.max_heartrate ?? detail?.max_heartrate, + ); + const calories = nonNegFloat(detail?.calories); + + const rawSportType = + summary.sport_type ?? summary.type ?? detail?.sport_type ?? "Workout"; + const sportType = mapStravaSportType(rawSportType); + + const metadata: Prisma.InputJsonValue = { + // Activity name/description are user free-text → DATA, never instructions. + ...(summary.name ? { stravaName: summary.name } : {}), + ...(detail?.description ? { stravaDescription: detail.description } : {}), + ...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}), + // Raw pre-mapping label, kept for provenance/debugging — never used as + // the canonical `sportType` itself (see `mapStravaSportType()`). + ...(rawSportType ? { stravaType: rawSportType } : {}), + ...(typeof summary.average_watts === "number" + ? { averageWatts: summary.average_watts } + : {}), + ...(typeof summary.weighted_average_watts === "number" + ? { weightedAverageWatts: summary.weighted_average_watts } + : {}), + ...(typeof summary.device_watts === "boolean" + ? { deviceWatts: summary.device_watts } + : {}), + ...(typeof summary.average_cadence === "number" + ? { averageCadence: summary.average_cadence } + : {}), + ...(typeof summary.trainer === "boolean" + ? { trainer: summary.trainer } + : {}), + ...(typeof summary.commute === "boolean" + ? { commute: summary.commute } + : {}), + ...(summary.gear_id ? { gearId: summary.gear_id } : {}), + }; + + return { + externalId: String(summary.id), + sportType, + startedAt, + endedAt, + durationSec, + totalEnergyKcal: calories, + totalDistanceM: nonNegFloat(summary.distance ?? detail?.distance), + avgHeartRate, + maxHeartRate, + elevationM: nonNegFloat( + summary.total_elevation_gain ?? detail?.total_elevation_gain, + ), + metadata, + summaryOnly: detail == null, + }; +} + +/** True when the summary already carries HR + calories are irrelevant — i.e. + * the summary alone is enough and a detail fetch can be skipped. Strava never + * puts `calories` on the summary, so a detail fetch is what fills it; callers + * use this only to decide whether HR is already covered. */ +export function summaryHasHeartRate(a: StravaSummaryActivity): boolean { + return ( + a.has_heartrate === true || + typeof a.average_heartrate === "number" || + typeof a.max_heartrate === "number" + ); +} diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 4d3560304..090b0d494 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -41,14 +41,9 @@ import { type FailureKind, } from "@/lib/integrations/status"; import { walkPages } from "@/lib/integrations/page-walk"; -import { - fetchActivities, - fetchActivityById, - mapActivity, - summaryHasHeartRate, - type StravaDetailedActivity, - type StravaWorkoutRow, -} from "./client"; +import { fetchActivities, fetchActivityById } from "./client"; +import { mapActivity, summaryHasHeartRate, type StravaWorkoutRow } from "./map"; +import { type StravaDetailedActivity } from "./wire-types"; import { getStravaConnection } from "./credentials"; import { refreshStravaAfterRefusal } from "./oauth-descriptor"; import { diff --git a/src/lib/strava/wire-types.ts b/src/lib/strava/wire-types.ts new file mode 100644 index 000000000..1f1cce0c9 --- /dev/null +++ b/src/lib/strava/wire-types.ts @@ -0,0 +1,37 @@ +/** + * The Strava API v3 wire shapes HealthLog reads: the list endpoint's + * `SummaryActivity` and the per-activity `DetailedActivity`. Shared by the + * endpoints (`./client.ts`) and the mapper (`./map.ts`). + */ +/** Strava `SummaryActivity` — the list-endpoint shape. Distances are metres, + * durations seconds, `start_date` a UTC ISO-8601 instant. HR / power fields are + * present when the activity recorded them; `calories` is NOT (detail only). */ +export interface StravaSummaryActivity { + id: number; + name?: string | null; + distance?: number | null; + moving_time?: number | null; + elapsed_time?: number | null; + total_elevation_gain?: number | null; + type?: string | null; + sport_type?: string | null; + start_date?: string | null; + start_date_local?: string | null; + average_heartrate?: number | null; + max_heartrate?: number | null; + has_heartrate?: boolean | null; + average_watts?: number | null; + weighted_average_watts?: number | null; + device_watts?: boolean | null; + average_cadence?: number | null; + trainer?: boolean | null; + commute?: boolean | null; + gear_id?: string | null; +} + +/** Strava `DetailedActivity` — the per-activity shape. Superset of the summary + * that additionally carries `calories` (kcal). */ +export interface StravaDetailedActivity extends StravaSummaryActivity { + calories?: number | null; + description?: string | null; +} diff --git a/src/lib/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts index b935e8a2c..eb83dfb5f 100644 --- a/src/lib/whoop/__tests__/client.test.ts +++ b/src/lib/whoop/__tests__/client.test.ts @@ -1,7 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - KJ_TO_KCAL, - WHOOP_FIELD_MAP, WHOOP_PAGE_LIMIT, fetchBodyMeasurement, fetchRecoveryByCycleId, @@ -13,13 +11,19 @@ import { fetchRecoveries, fetchSleeps, fetchWorkouts, +} from "../client"; +import { + KJ_TO_KCAL, + WHOOP_FIELD_MAP, mapCycle, mapRecovery, mapSleep, +} from "@/lib/whoop/map"; +import { type WhoopCycle, type WhoopRecovery, type WhoopSleep, -} from "../client"; +} from "@/lib/whoop/wire-types"; import { WHOOP_OAUTH_SCOPE } from "@/lib/whoop/oauth"; import { WhoopApiError } from "../response-classifier"; import { whoopOAuth } from "../oauth"; diff --git a/src/lib/whoop/__tests__/map-fixtures.test.ts b/src/lib/whoop/__tests__/map-fixtures.test.ts index 590fae371..fe2506787 100644 --- a/src/lib/whoop/__tests__/map-fixtures.test.ts +++ b/src/lib/whoop/__tests__/map-fixtures.test.ts @@ -14,16 +14,13 @@ import { format } from "prettier"; import { describe, expect, it } from "vitest"; +import { mapBody, mapCycle, mapRecovery, mapSleep } from "@/lib/whoop/map"; import { - mapBody, - mapCycle, - mapRecovery, - mapSleep, type WhoopBodyMeasurement, type WhoopCycle, type WhoopRecovery, type WhoopSleep, -} from "../client"; +} from "@/lib/whoop/wire-types"; import { mapWhoopSportType } from "../sport-map"; import bodyFixture from "./__fixtures__/body.json"; import cycleFixture from "./__fixtures__/cycle.json"; diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index 4237ff0fc..c87dfa244 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -76,7 +76,7 @@ vi.mock("../sync-core", async (orig) => { }; }); -import { mapBody } from "../client"; +import { mapBody } from "@/lib/whoop/map"; import { syncUserBody, WHOOP_BODY_WEIGHT_EXTERNAL_ID } from "../sync-body"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; diff --git a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts index aadc10620..b20010152 100644 --- a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts +++ b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts @@ -64,7 +64,7 @@ vi.mock("../client", async (importOriginal) => ({ fetchSleepById: fetchSleepsMock, })); -import type { WhoopSleep } from "../client"; +import type { WhoopSleep } from "@/lib/whoop/wire-types"; import { syncUserSleep } from "../sync-sleep"; const NIGHT: WhoopSleep = { diff --git a/src/lib/whoop/__tests__/sync-workout.test.ts b/src/lib/whoop/__tests__/sync-workout.test.ts index ac995ac3c..c853b108f 100644 --- a/src/lib/whoop/__tests__/sync-workout.test.ts +++ b/src/lib/whoop/__tests__/sync-workout.test.ts @@ -47,7 +47,7 @@ vi.mock("@/lib/logging/context", () => ({ })); import { upsertWhoopWorkout } from "../sync-workout"; -import type { WhoopWorkout } from "../client"; +import type { WhoopWorkout } from "@/lib/whoop/wire-types"; function scoredWorkout(overrides: Partial): WhoopWorkout { return { diff --git a/src/lib/whoop/__tests__/webhook-notification.test.ts b/src/lib/whoop/__tests__/webhook-notification.test.ts index ba08d8caa..385ca48cd 100644 --- a/src/lib/whoop/__tests__/webhook-notification.test.ts +++ b/src/lib/whoop/__tests__/webhook-notification.test.ts @@ -62,9 +62,8 @@ import { mapRecovery, mapSleep, WHOOP_RECOVERY_FIELD_TAGS, - type WhoopRecovery, - type WhoopSleep, -} from "../client"; +} from "@/lib/whoop/map"; +import { type WhoopRecovery, type WhoopSleep } from "@/lib/whoop/wire-types"; import type { Prisma } from "@/generated/prisma/client"; beforeEach(() => { diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 70f3aa859..0117dbf80 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -1,152 +1,35 @@ /** - * WHOOP API v2 client for OAuth and data fetching. - * Docs: https://developer.whoop.com (re-verify at build — the space moves). + * WHOOP API v2 transport and endpoints: hand-rolled fetch over `safeFetch` + * (no SDK), collection reads with `next_token` pagination and single-record + * reads. Docs: https://developer.whoop.com (re-verify at build; the space + * moves). * - * Hand-rolled fetch over `safeFetch` (no SDK): typed collection fetchers with - * `next_token` pagination, and a single source-of-truth field→Measurement - * mapping (`WHOOP_FIELD_MAP`, kept in sync with `src/lib/whoop/mapping.md`). - * The OAuth handshake is `./oauth.ts`. - * - * v2 only — v1 was removed 2025-10-01. Sleep / workout / recovery ids are - * UUID strings; cycle id is int64. Token TTL 3600 s; refresh tokens ROTATE - * (each refresh invalidates the prior access AND refresh token — the sync - * layer persists BOTH rotated tokens). The `offline` scope is required to - * receive a refresh token at all. + * v2 only (v1 was removed 2025-10-01). Sleep, workout and recovery ids are + * UUID strings; cycle ids are int64. The wire shapes are `./wire-types.ts`, + * the record → measurement mapping is `./map.ts`, the OAuth handshake is + * `./oauth.ts`. */ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; -import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; + import { WhoopApiError, classifyWhoopResponse } from "./response-classifier"; -import { importValue } from "@/lib/integrations/import-value"; + import { walkPages } from "@/lib/integrations/page-walk"; +import type { + WhoopCollection, + WhoopRecovery, + WhoopSleep, + WhoopCycle, + WhoopWorkout, + WhoopBodyMeasurement, + WhoopProfile, +} from "./wire-types"; const WHOOP_API_BASE = "https://api.prod.whoop.com/developer"; /** WHOOP collection reads cap `limit` at 25. */ export const WHOOP_PAGE_LIMIT = 25; -// ─── Collection envelopes (v2) ───────────────────────────────── - -/** WHOOP paginated collection envelope: `{ records, next_token }`. */ -export interface WhoopCollection { - records: T[]; - next_token?: string | null; -} - -/** Recovery record. `score` is null until WHOOP finishes scoring. */ -export interface WhoopRecovery { - cycle_id: number; - sleep_id: string; - user_id: number; - created_at: string; - updated_at: string; - score_state: string; - score: { - user_calibrating: boolean; - recovery_score: number; - resting_heart_rate: number; - hrv_rmssd_milli: number; - spo2_percentage?: number; - skin_temp_celsius?: number; - } | null; -} - -/** Sleep activity record. */ -export interface WhoopSleep { - id: string; - /** - * The cycle this sleep belongs to. A v2 recovery is read through its cycle, - * and the recovery webhook carries the SLEEP id, so the targeted recovery - * refresh resolves sleep -> cycle -> recovery through this field. - */ - cycle_id?: number; - user_id: number; - created_at: string; - updated_at: string; - start: string; - end: string; - nap: boolean; - score_state: string; - score: { - stage_summary: { - total_in_bed_time_milli: number; - total_awake_time_milli: number; - total_light_sleep_time_milli: number; - total_slow_wave_sleep_time_milli: number; - total_rem_sleep_time_milli: number; - // v1.12.8 — per-night sleep disturbance tally. Optional: older / still- - // scoring records may omit it. - disturbance_count?: number; - }; - sleep_needed: { - baseline_milli: number; - need_from_sleep_debt_milli: number; - need_from_recent_strain_milli: number; - need_from_recent_nap_milli: number; - }; - respiratory_rate?: number; - sleep_performance_percentage?: number; - sleep_consistency_percentage?: number; - sleep_efficiency_percentage?: number; - } | null; -} - -/** Physiological cycle (day) record. `id` is an int64. */ -export interface WhoopCycle { - id: number; - user_id: number; - created_at: string; - updated_at: string; - start: string; - end?: string | null; - score_state: string; - score: { - strain: number; - kilojoule: number; - average_heart_rate: number; - max_heart_rate: number; - } | null; -} - -/** Workout activity record. */ -export interface WhoopWorkout { - id: string; - user_id: number; - created_at: string; - updated_at: string; - start: string; - end: string; - sport_id?: number; - sport_name?: string; - score_state: string; - score: { - strain: number; - average_heart_rate: number; - max_heart_rate: number; - kilojoule: number; - percent_recorded: number; - distance_meter?: number; - altitude_gain_meter?: number; - altitude_change_meter?: number; - zone_durations?: Record; - } | null; -} - -/** Body-measurement object (single, not paginated). */ -export interface WhoopBodyMeasurement { - height_meter?: number; - weight_kilogram?: number; - max_heart_rate?: number; -} - -/** Basic profile object (single, not paginated). */ -export interface WhoopProfile { - user_id: number; - email?: string; - first_name?: string; - last_name?: string; -} - interface CollectionQuery { start?: Date; end?: Date; @@ -362,430 +245,3 @@ export function fetchProfile(accessToken: string): Promise { "fetchProfile", ); } - -// ─── Field → Measurement mapping ─────────────────────────────── -// The single source of truth is `src/lib/whoop/mapping.md` — keep both in -// sync when adding entries. - -/** Milliseconds → minutes. */ -const MS_TO_MIN = 1 / 60_000; -/** Kilojoules → kilocalories (workout energy). */ -export const KJ_TO_KCAL = 1 / 4.184; - -/** - * A single mapped reading destined for one `Measurement` row. The `source` - * (`WHOOP`) and `externalId` (`:`) are stamped by the - * sync layer (W3); the mapper only emits the type/value/unit/measuredAt and - * the field-tag that disambiguates the several rows derived from one resource. - */ -export interface MappedMeasurement { - type: string; - value: number; - unit: string; - measuredAt: Date; - /** Disambiguator appended to the resource uuid to form the externalId. */ - fieldTag: string; - /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ - sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; - /** - * Full externalId for rows the mapper must key itself rather than letting - * the sync layer build `:`. The reconstructed sleep - * segments (one row per synthetic span) need an index in the key 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 the synthesised per-segment sleep rows whose ORDER is invented: - * WHOOP's v2 API returns only per-stage duration totals (no onset - * timestamps), so the timeline is reconstructed in a fixed physiological - * order. The night is flagged so the UI labels it as an approximate layout - * and never presents it as measured stage timing. - */ - reconstructed?: boolean; -} - -/** - * Every field-tag `mapRecovery` can emit. A recovery's rows are keyed - * `:` and share the `:` prefix with the rows of - * the sleep it belongs to, so a recovery deletion must name these exact ids - * rather than match on the prefix (which would take the night's sleep rows - * with it). Pinned against the mapper by a test. - */ -export const WHOOP_RECOVERY_FIELD_TAGS = [ - "recovery", - "hrv_rmssd", - "rhr", - "spo2", - "skin_temp", -] as const; - -/** - * Map one WHOOP recovery record into Measurement readings. An unscored record - * (`score === null`) yields nothing. `recovery.score.recovery_score` → - * `RECOVERY_SCORE` (source WHOOP, distinct from the COMPUTED proxy); - * `hrv_rmssd_milli` → `HRV_RMSSD` (distinct from the SDNN - * `HEART_RATE_VARIABILITY`); RHR / SpO2 / skin-temp resolve through the - * cross-source picker. - */ -export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] { - if (!r.score) return []; - const measuredAt = new Date(r.updated_at); - const out: MappedMeasurement[] = [ - { - type: "RECOVERY_SCORE", - value: importValue(r.score.recovery_score), - unit: "score", - measuredAt, - fieldTag: "recovery", - }, - { - type: "HRV_RMSSD", - value: importValue(r.score.hrv_rmssd_milli), - unit: "ms", - measuredAt, - fieldTag: "hrv_rmssd", - }, - { - type: "RESTING_HEART_RATE", - value: importValue(r.score.resting_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "rhr", - }, - ]; - if (typeof r.score.spo2_percentage === "number") { - out.push({ - type: "OXYGEN_SATURATION", - value: importValue(r.score.spo2_percentage), - unit: "%", - measuredAt, - fieldTag: "spo2", - }); - } - if (typeof r.score.skin_temp_celsius === "number") { - out.push({ - type: "SKIN_TEMPERATURE", - value: importValue(r.score.skin_temp_celsius), - unit: "celsius", - measuredAt, - fieldTag: "skin_temp", - }); - } - return out; -} - -/** - * Map one WHOOP sleep record into Measurement readings: per-stage - * `SLEEP_DURATION` rows (ms→min, one per stage), `SLEEP_NEED` (ms→min, summed - * components), the `SLEEP_*` percentage scores, and `RESPIRATORY_RATE`. - */ -export function mapSleep(s: WhoopSleep): MappedMeasurement[] { - if (!s.score) return []; - const measuredAt = new Date(s.end); - const out: MappedMeasurement[] = []; - - const stages = s.score.stage_summary; - - // WHOOP v2 returns only per-stage DURATION totals (no per-stage onset - // timestamps, no hypnogram endpoint). Stamping every stage total on the one - // sleep-END instant makes the hypnogram reconstruct five spans that all touch - // the night's right edge (the stacked-bar artefact). Since the API never - // carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the - // asleep stages back-to-back from sleep ONSET in a fixed physiological order, - // emitting one timed row per segment with `measuredAt = that segment's END`. - // The ORDER is synthetic, so every reconstructed row is flagged - // `reconstructed: true` and the night DTO advertises it — the UI labels it as - // an approximate layout and never presents it as measured stage timing. - // - // IN_BED stays a single envelope row spanning the whole [start, end] window: - // the in-bed reader wants the union envelope, not a placed segment. AWAKE is - // laid as a leading segment from onset so the asleep stages still partition - // the remaining window. - const onset = new Date(s.start).getTime(); - // Order the laid-out stages: a brief AWAKE settling-in block, then the asleep - // stages CORE → DEEP → REM. Each carries its own duration; the shared builder - // walks the cursor forward so the spans are contiguous and non-overlapping, - // and lays the IN_BED envelope over the whole window. The order is synthetic - // (WHOOP gives no per-stage onset), so every laid segment is flagged - // reconstructed; the same algorithm backs Polar's `mapSleep`. - out.push( - ...reconstructContiguousSleepTimeline({ - startMs: onset, - stages: [ - { - durationMs: stages.total_awake_time_milli, - stage: "AWAKE", - fieldTag: "sleep_awake", - }, - { - durationMs: stages.total_light_sleep_time_milli, - stage: "CORE", - fieldTag: "sleep_core", - }, - { - durationMs: stages.total_slow_wave_sleep_time_milli, - stage: "DEEP", - fieldTag: "sleep_deep", - }, - { - durationMs: stages.total_rem_sleep_time_milli, - stage: "REM", - fieldTag: "sleep_rem", - }, - ], - // IN_BED — single envelope row over the whole sleep window. `measuredAt` - // is the sleep END so `segmentOf` resolves the span back to [start, end]; - // the in-bed union-envelope reader consumes the full window. - inBed: { - durationMs: stages.total_in_bed_time_milli, - measuredAt, - fieldTag: "sleep_in_bed", - }, - // The sync layer keys reconstructed rows by this externalId verbatim; - // the stage tag keeps the several rows of one night distinct under - // userId_type_source_externalId (one segment per stage, no positional - // index — an index renumbered on a re-score when a stage's duration - // flipped 0↔positive, minting fresh ids that double-counted the night). - externalIdFor: (tag) => `${s.id}:seg:${tag}`, - }), - ); - - const need = s.score.sleep_needed; - const totalNeedMilli = - need.baseline_milli + - need.need_from_sleep_debt_milli + - need.need_from_recent_strain_milli + - need.need_from_recent_nap_milli; - out.push({ - type: "SLEEP_NEED", - value: importValue(totalNeedMilli * MS_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag: "sleep_need", - }); - - const pct: Array<[string, number | undefined, string]> = [ - ["SLEEP_PERFORMANCE", s.score.sleep_performance_percentage, "sleep_perf"], - ["SLEEP_EFFICIENCY", s.score.sleep_efficiency_percentage, "sleep_eff"], - [ - "SLEEP_CONSISTENCY", - s.score.sleep_consistency_percentage, - "sleep_consistency", - ], - ]; - for (const [type, value, fieldTag] of pct) { - if (typeof value === "number") { - out.push({ - type, - value: importValue(value), - unit: "%", - measuredAt, - fieldTag, - }); - } - } - - if (typeof s.score.respiratory_rate === "number") { - out.push({ - type: "RESPIRATORY_RATE", - value: importValue(s.score.respiratory_rate), - unit: "breaths/min", - measuredAt, - fieldTag: "resp_rate", - }); - } - - if (typeof stages.disturbance_count === "number") { - out.push({ - type: "SLEEP_DISTURBANCE_COUNT", - value: stages.disturbance_count, - unit: "count", - measuredAt, - fieldTag: "disturbances", - }); - } - - return out; -} - -/** - * Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED - * `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's - * whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted - * to kcal here — that conversion is for the workout path only. - */ -export function mapCycle(c: WhoopCycle): MappedMeasurement[] { - if (!c.score) return []; - const measuredAt = new Date(c.start); - const out: MappedMeasurement[] = [ - { - type: "DAY_STRAIN", - value: importValue(c.score.strain), - unit: "score", - measuredAt, - fieldTag: "day_strain", - }, - { - type: "ENERGY_EXPENDITURE_KJ", - value: importValue(c.score.kilojoule), - unit: "kJ", - measuredAt, - fieldTag: "energy_kj", - }, - ]; - - // v1.12.8 — the cycle score's average / max heart rate were fetched but - // dropped before this release. Distinct fieldTags keep them from colliding - // with the strain / energy rows under the shared cycle `externalId`. - if (typeof c.score.average_heart_rate === "number") { - out.push({ - type: "AVERAGE_HEART_RATE", - value: Math.round(c.score.average_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "avg_hr", - }); - } - if (typeof c.score.max_heart_rate === "number") { - out.push({ - type: "MAX_HEART_RATE", - value: Math.round(c.score.max_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "max_hr", - }); - } - - return out; -} - -/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */ -const M_TO_CM = 100; - -/** - * The three destinations a WHOOP body-measurement object fans out to. Unlike - * the collection mappers it does NOT emit `MappedMeasurement[]` directly, - * because only `weight` lands in `Measurement` — `maxHeartRate` is a profile - * constant on `WhoopConnection` and `heightCm` is a one-time `User` profile - * seed (written only when the user has no height yet). The sync layer routes - * each piece to its own table. - */ -export interface MappedBodyMeasurement { - /** Self-reported profile weight in kg, or null when WHOOP omits it. */ - weightKg: number | null; - /** Profile max heart rate in bpm, or null when WHOOP omits it. */ - maxHeartRate: number | null; - /** Profile height converted m→cm, or null when WHOOP omits it. */ - heightCm: number | null; -} - -/** - * Map a WHOOP body-measurement object onto its three destinations. The body - * measurement is a single self-reported profile value, not a timestamped - * reading, so the sync layer stamps the weight row's `measuredAt` with the - * fetch time. Every field is optional on the wire — an absent field maps to - * null and the sync layer skips it. - */ -export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement { - // WHOOP body fields are self-reported profile data. Guard against absent, - // non-finite (NaN/Infinity), or non-positive values so a garbage reading - // never seeds a real WEIGHT measurement or a `User.heightCm` of 0 - // (v1.11.3 QA L1). - const positive = (n: number | undefined): n is number => - typeof n === "number" && Number.isFinite(n) && n > 0; - return { - weightKg: positive(b.weight_kilogram) - ? importValue(b.weight_kilogram) - : null, - maxHeartRate: positive(b.max_heart_rate) - ? Math.round(b.max_heart_rate) - : null, - heightCm: positive(b.height_meter) - ? importValue(b.height_meter * M_TO_CM) - : null, - }; -} - -/** - * Field→Measurement mapping table (mirror of `mapping.md`). Documents which - * WHOOP source field becomes which MeasurementType + unit. Used as the - * single-glance reference and by the mapper tests; the mappers above are the - * executable form. - */ -export const WHOOP_FIELD_MAP: Record< - string, - { type: string; unit: string; factor?: number; note?: string } -> = { - "recovery.score.recovery_score": { type: "RECOVERY_SCORE", unit: "score" }, - "recovery.score.hrv_rmssd_milli": { type: "HRV_RMSSD", unit: "ms" }, - "recovery.score.resting_heart_rate": { - type: "RESTING_HEART_RATE", - unit: "bpm", - }, - "recovery.score.spo2_percentage": { - type: "OXYGEN_SATURATION", - unit: "%", - }, - "recovery.score.skin_temp_celsius": { - type: "SKIN_TEMPERATURE", - unit: "celsius", - }, - "sleep.score.stage_summary.*_time_milli": { - type: "SLEEP_DURATION", - unit: "minutes", - factor: MS_TO_MIN, - note: "one row per stage (light→CORE, slow-wave→DEEP, rem→REM, awake→AWAKE, in-bed→IN_BED)", - }, - "sleep.score.sleep_needed.*_milli": { - type: "SLEEP_NEED", - unit: "minutes", - factor: MS_TO_MIN, - note: "baseline + debt + strain + nap components summed", - }, - "sleep.score.sleep_performance_percentage": { - type: "SLEEP_PERFORMANCE", - unit: "%", - }, - "sleep.score.sleep_efficiency_percentage": { - type: "SLEEP_EFFICIENCY", - unit: "%", - }, - "sleep.score.sleep_consistency_percentage": { - type: "SLEEP_CONSISTENCY", - unit: "%", - }, - "sleep.score.respiratory_rate": { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - }, - "cycle.score.strain": { type: "DAY_STRAIN", unit: "score" }, - "cycle.score.kilojoule": { type: "ENERGY_EXPENDITURE_KJ", unit: "kJ" }, - "workout.score.strain": { - type: "WORKOUT_STRAIN", - unit: "score", - note: "preferentially stored in Workout.metadata, not a free-floating Measurement", - }, - "workout.score.kilojoule": { - type: "Workout.totalEnergyKcal", - unit: "kcal", - factor: KJ_TO_KCAL, - note: "kJ→kcal for the workout row", - }, - "body.weight_kilogram": { - type: "WEIGHT", - unit: "kg", - note: "picker ranks a real scale above WHOOP", - }, - "body.max_heart_rate": { - type: "WhoopConnection.maxHeartRate", - unit: "bpm", - note: "profile constant — stored on the connection, not a Measurement", - }, - "body.height_meter": { - type: "User.heightCm", - unit: "cm", - factor: M_TO_CM, - note: "profile seed — written to User.heightCm only when it is null, never as a Measurement", - }, -}; diff --git a/src/lib/whoop/map.ts b/src/lib/whoop/map.ts new file mode 100644 index 000000000..3c9d441a9 --- /dev/null +++ b/src/lib/whoop/map.ts @@ -0,0 +1,441 @@ +/** + * WHOOP records to measurement rows: pure functions over the wire shapes + * (`./wire-types.ts`), with no I/O, and the field map kept in sync with + * `./mapping.md`. `__tests__/map-fixtures.test.ts` pins what each makes of + * sanitised fixtures. + */ +import { importValue } from "@/lib/integrations/import-value"; +import type { + WhoopRecovery, + WhoopSleep, + WhoopCycle, + WhoopBodyMeasurement, +} from "./wire-types"; +import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; + +// ─── Field → Measurement mapping ─────────────────────────────── +// The single source of truth is `src/lib/whoop/mapping.md` — keep both in +// sync when adding entries. + +/** Milliseconds → minutes. */ +const MS_TO_MIN = 1 / 60_000; + +/** Kilojoules → kilocalories (workout energy). */ +export const KJ_TO_KCAL = 1 / 4.184; + +/** + * A single mapped reading destined for one `Measurement` row. The `source` + * (`WHOOP`) and `externalId` (`:`) are stamped by the + * sync layer (W3); the mapper only emits the type/value/unit/measuredAt and + * the field-tag that disambiguates the several rows derived from one resource. + */ +export interface MappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + /** Disambiguator appended to the resource uuid to form the externalId. */ + fieldTag: string; + /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; + /** + * Full externalId for rows the mapper must key itself rather than letting + * the sync layer build `:`. The reconstructed sleep + * segments (one row per synthetic span) need an index in the key 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 the synthesised per-segment sleep rows whose ORDER is invented: + * WHOOP's v2 API returns only per-stage duration totals (no onset + * timestamps), so the timeline is reconstructed in a fixed physiological + * order. The night is flagged so the UI labels it as an approximate layout + * and never presents it as measured stage timing. + */ + reconstructed?: boolean; +} + +/** + * Every field-tag `mapRecovery` can emit. A recovery's rows are keyed + * `:` and share the `:` prefix with the rows of + * the sleep it belongs to, so a recovery deletion must name these exact ids + * rather than match on the prefix (which would take the night's sleep rows + * with it). Pinned against the mapper by a test. + */ +export const WHOOP_RECOVERY_FIELD_TAGS = [ + "recovery", + "hrv_rmssd", + "rhr", + "spo2", + "skin_temp", +] as const; + +/** + * Map one WHOOP recovery record into Measurement readings. An unscored record + * (`score === null`) yields nothing. `recovery.score.recovery_score` → + * `RECOVERY_SCORE` (source WHOOP, distinct from the COMPUTED proxy); + * `hrv_rmssd_milli` → `HRV_RMSSD` (distinct from the SDNN + * `HEART_RATE_VARIABILITY`); RHR / SpO2 / skin-temp resolve through the + * cross-source picker. + */ +export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] { + if (!r.score) return []; + const measuredAt = new Date(r.updated_at); + const out: MappedMeasurement[] = [ + { + type: "RECOVERY_SCORE", + value: importValue(r.score.recovery_score), + unit: "score", + measuredAt, + fieldTag: "recovery", + }, + { + type: "HRV_RMSSD", + value: importValue(r.score.hrv_rmssd_milli), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + }, + { + type: "RESTING_HEART_RATE", + value: importValue(r.score.resting_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + }, + ]; + if (typeof r.score.spo2_percentage === "number") { + out.push({ + type: "OXYGEN_SATURATION", + value: importValue(r.score.spo2_percentage), + unit: "%", + measuredAt, + fieldTag: "spo2", + }); + } + if (typeof r.score.skin_temp_celsius === "number") { + out.push({ + type: "SKIN_TEMPERATURE", + value: importValue(r.score.skin_temp_celsius), + unit: "celsius", + measuredAt, + fieldTag: "skin_temp", + }); + } + return out; +} + +/** + * Map one WHOOP sleep record into Measurement readings: per-stage + * `SLEEP_DURATION` rows (ms→min, one per stage), `SLEEP_NEED` (ms→min, summed + * components), the `SLEEP_*` percentage scores, and `RESPIRATORY_RATE`. + */ +export function mapSleep(s: WhoopSleep): MappedMeasurement[] { + if (!s.score) return []; + const measuredAt = new Date(s.end); + const out: MappedMeasurement[] = []; + + const stages = s.score.stage_summary; + + // WHOOP v2 returns only per-stage DURATION totals (no per-stage onset + // timestamps, no hypnogram endpoint). Stamping every stage total on the one + // sleep-END instant makes the hypnogram reconstruct five spans that all touch + // the night's right edge (the stacked-bar artefact). Since the API never + // carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the + // asleep stages back-to-back from sleep ONSET in a fixed physiological order, + // emitting one timed row per segment with `measuredAt = that segment's END`. + // The ORDER is synthetic, so every reconstructed row is flagged + // `reconstructed: true` and the night DTO advertises it — the UI labels it as + // an approximate layout and never presents it as measured stage timing. + // + // IN_BED stays a single envelope row spanning the whole [start, end] window: + // the in-bed reader wants the union envelope, not a placed segment. AWAKE is + // laid as a leading segment from onset so the asleep stages still partition + // the remaining window. + const onset = new Date(s.start).getTime(); + // Order the laid-out stages: a brief AWAKE settling-in block, then the asleep + // stages CORE → DEEP → REM. Each carries its own duration; the shared builder + // walks the cursor forward so the spans are contiguous and non-overlapping, + // and lays the IN_BED envelope over the whole window. The order is synthetic + // (WHOOP gives no per-stage onset), so every laid segment is flagged + // reconstructed; the same algorithm backs Polar's `mapSleep`. + out.push( + ...reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [ + { + durationMs: stages.total_awake_time_milli, + stage: "AWAKE", + fieldTag: "sleep_awake", + }, + { + durationMs: stages.total_light_sleep_time_milli, + stage: "CORE", + fieldTag: "sleep_core", + }, + { + durationMs: stages.total_slow_wave_sleep_time_milli, + stage: "DEEP", + fieldTag: "sleep_deep", + }, + { + durationMs: stages.total_rem_sleep_time_milli, + stage: "REM", + fieldTag: "sleep_rem", + }, + ], + // IN_BED — single envelope row over the whole sleep window. `measuredAt` + // is the sleep END so `segmentOf` resolves the span back to [start, end]; + // the in-bed union-envelope reader consumes the full window. + inBed: { + durationMs: stages.total_in_bed_time_milli, + measuredAt, + fieldTag: "sleep_in_bed", + }, + // The sync layer keys reconstructed rows by this externalId verbatim; + // the stage tag keeps the several rows of one night distinct under + // userId_type_source_externalId (one segment per stage, no positional + // index — an index renumbered on a re-score when a stage's duration + // flipped 0↔positive, minting fresh ids that double-counted the night). + externalIdFor: (tag) => `${s.id}:seg:${tag}`, + }), + ); + + const need = s.score.sleep_needed; + const totalNeedMilli = + need.baseline_milli + + need.need_from_sleep_debt_milli + + need.need_from_recent_strain_milli + + need.need_from_recent_nap_milli; + out.push({ + type: "SLEEP_NEED", + value: importValue(totalNeedMilli * MS_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag: "sleep_need", + }); + + const pct: Array<[string, number | undefined, string]> = [ + ["SLEEP_PERFORMANCE", s.score.sleep_performance_percentage, "sleep_perf"], + ["SLEEP_EFFICIENCY", s.score.sleep_efficiency_percentage, "sleep_eff"], + [ + "SLEEP_CONSISTENCY", + s.score.sleep_consistency_percentage, + "sleep_consistency", + ], + ]; + for (const [type, value, fieldTag] of pct) { + if (typeof value === "number") { + out.push({ + type, + value: importValue(value), + unit: "%", + measuredAt, + fieldTag, + }); + } + } + + if (typeof s.score.respiratory_rate === "number") { + out.push({ + type: "RESPIRATORY_RATE", + value: importValue(s.score.respiratory_rate), + unit: "breaths/min", + measuredAt, + fieldTag: "resp_rate", + }); + } + + if (typeof stages.disturbance_count === "number") { + out.push({ + type: "SLEEP_DISTURBANCE_COUNT", + value: stages.disturbance_count, + unit: "count", + measuredAt, + fieldTag: "disturbances", + }); + } + + return out; +} + +/** + * Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED + * `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's + * whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted + * to kcal here — that conversion is for the workout path only. + */ +export function mapCycle(c: WhoopCycle): MappedMeasurement[] { + if (!c.score) return []; + const measuredAt = new Date(c.start); + const out: MappedMeasurement[] = [ + { + type: "DAY_STRAIN", + value: importValue(c.score.strain), + unit: "score", + measuredAt, + fieldTag: "day_strain", + }, + { + type: "ENERGY_EXPENDITURE_KJ", + value: importValue(c.score.kilojoule), + unit: "kJ", + measuredAt, + fieldTag: "energy_kj", + }, + ]; + + // The cycle score's average and max heart rate. Distinct fieldTags keep them + // from colliding with the strain and energy rows under the shared cycle + // `externalId`. + if (typeof c.score.average_heart_rate === "number") { + out.push({ + type: "AVERAGE_HEART_RATE", + value: Math.round(c.score.average_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "avg_hr", + }); + } + if (typeof c.score.max_heart_rate === "number") { + out.push({ + type: "MAX_HEART_RATE", + value: Math.round(c.score.max_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "max_hr", + }); + } + + return out; +} + +/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */ +const M_TO_CM = 100; + +/** + * The three destinations a WHOOP body-measurement object fans out to. Unlike + * the collection mappers it does NOT emit `MappedMeasurement[]` directly, + * because only `weight` lands in `Measurement` — `maxHeartRate` is a profile + * constant on `WhoopConnection` and `heightCm` is a one-time `User` profile + * seed (written only when the user has no height yet). The sync layer routes + * each piece to its own table. + */ +export interface MappedBodyMeasurement { + /** Self-reported profile weight in kg, or null when WHOOP omits it. */ + weightKg: number | null; + /** Profile max heart rate in bpm, or null when WHOOP omits it. */ + maxHeartRate: number | null; + /** Profile height converted m→cm, or null when WHOOP omits it. */ + heightCm: number | null; +} + +/** + * Map a WHOOP body-measurement object onto its three destinations. The body + * measurement is a single self-reported profile value, not a timestamped + * reading, so the sync layer stamps the weight row's `measuredAt` with the + * fetch time. Every field is optional on the wire — an absent field maps to + * null and the sync layer skips it. + */ +export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement { + // WHOOP body fields are self-reported profile data. Guard against absent, + // non-finite (NaN/Infinity), or non-positive values so a garbage reading + // never seeds a real WEIGHT measurement or a `User.heightCm` of 0. + const positive = (n: number | undefined): n is number => + typeof n === "number" && Number.isFinite(n) && n > 0; + return { + weightKg: positive(b.weight_kilogram) + ? importValue(b.weight_kilogram) + : null, + maxHeartRate: positive(b.max_heart_rate) + ? Math.round(b.max_heart_rate) + : null, + heightCm: positive(b.height_meter) + ? importValue(b.height_meter * M_TO_CM) + : null, + }; +} + +/** + * Field→Measurement mapping table (mirror of `mapping.md`). Documents which + * WHOOP source field becomes which MeasurementType + unit. Used as the + * single-glance reference and by the mapper tests; the mappers above are the + * executable form. + */ +export const WHOOP_FIELD_MAP: Record< + string, + { type: string; unit: string; factor?: number; note?: string } +> = { + "recovery.score.recovery_score": { type: "RECOVERY_SCORE", unit: "score" }, + "recovery.score.hrv_rmssd_milli": { type: "HRV_RMSSD", unit: "ms" }, + "recovery.score.resting_heart_rate": { + type: "RESTING_HEART_RATE", + unit: "bpm", + }, + "recovery.score.spo2_percentage": { + type: "OXYGEN_SATURATION", + unit: "%", + }, + "recovery.score.skin_temp_celsius": { + type: "SKIN_TEMPERATURE", + unit: "celsius", + }, + "sleep.score.stage_summary.*_time_milli": { + type: "SLEEP_DURATION", + unit: "minutes", + factor: MS_TO_MIN, + note: "one row per stage (light→CORE, slow-wave→DEEP, rem→REM, awake→AWAKE, in-bed→IN_BED)", + }, + "sleep.score.sleep_needed.*_milli": { + type: "SLEEP_NEED", + unit: "minutes", + factor: MS_TO_MIN, + note: "baseline + debt + strain + nap components summed", + }, + "sleep.score.sleep_performance_percentage": { + type: "SLEEP_PERFORMANCE", + unit: "%", + }, + "sleep.score.sleep_efficiency_percentage": { + type: "SLEEP_EFFICIENCY", + unit: "%", + }, + "sleep.score.sleep_consistency_percentage": { + type: "SLEEP_CONSISTENCY", + unit: "%", + }, + "sleep.score.respiratory_rate": { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + }, + "cycle.score.strain": { type: "DAY_STRAIN", unit: "score" }, + "cycle.score.kilojoule": { type: "ENERGY_EXPENDITURE_KJ", unit: "kJ" }, + "workout.score.strain": { + type: "WORKOUT_STRAIN", + unit: "score", + note: "preferentially stored in Workout.metadata, not a free-floating Measurement", + }, + "workout.score.kilojoule": { + type: "Workout.totalEnergyKcal", + unit: "kcal", + factor: KJ_TO_KCAL, + note: "kJ→kcal for the workout row", + }, + "body.weight_kilogram": { + type: "WEIGHT", + unit: "kg", + note: "picker ranks a real scale above WHOOP", + }, + "body.max_heart_rate": { + type: "WhoopConnection.maxHeartRate", + unit: "bpm", + note: "profile constant — stored on the connection, not a Measurement", + }, + "body.height_meter": { + type: "User.heightCm", + unit: "cm", + factor: M_TO_CM, + note: "profile seed — written to User.heightCm only when it is null, never as a Measurement", + }, +}; diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index 5a3ca040a..cfdabaa54 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -20,7 +20,8 @@ * 403 soft-skips this data class (returns 0, leaves the connection connected) * rather than parking the whole integration — see `isCollectionForbidden`. */ -import { fetchBodyMeasurement, mapBody } from "./client"; +import { fetchBodyMeasurement } from "./client"; +import { mapBody } from "./map"; import { getValidToken, handleCollectionFetchError, diff --git a/src/lib/whoop/sync-cycle.ts b/src/lib/whoop/sync-cycle.ts index 39c0488ad..b6f635649 100644 --- a/src/lib/whoop/sync-cycle.ts +++ b/src/lib/whoop/sync-cycle.ts @@ -5,7 +5,8 @@ * * Cycle id is an int64 (not a UUID); externalId = `cycle::`. */ -import { fetchCycles, mapCycle } from "./client"; +import { fetchCycles } from "./client"; +import { mapCycle } from "./map"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/sync-recovery.ts b/src/lib/whoop/sync-recovery.ts index 496371ed7..24014d8b2 100644 --- a/src/lib/whoop/sync-recovery.ts +++ b/src/lib/whoop/sync-recovery.ts @@ -13,8 +13,8 @@ import { fetchRecoveries, fetchRecoveryByCycleId, fetchSleepById, - mapRecovery, } from "./client"; +import { mapRecovery } from "./map"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 3adf29ad5..7bbb34ad3 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -27,7 +27,8 @@ import { } from "@/lib/sleep/sweep-stale-segments"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; -import { fetchSleeps, fetchSleepById, mapSleep } from "./client"; +import { fetchSleeps, fetchSleepById } from "./client"; +import { mapSleep } from "./map"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/sync-workout.ts b/src/lib/whoop/sync-workout.ts index 046e38d33..2fe65cefb 100644 --- a/src/lib/whoop/sync-workout.ts +++ b/src/lib/whoop/sync-workout.ts @@ -19,12 +19,9 @@ * source's. Raw `sport_id` / `sport_name` still live in `Workout.metadata` * for traceability. */ -import { - fetchWorkouts, - fetchWorkoutById, - KJ_TO_KCAL, - type WhoopWorkout, -} from "./client"; +import { fetchWorkouts, fetchWorkoutById } from "./client"; +import { KJ_TO_KCAL } from "./map"; +import { type WhoopWorkout } from "./wire-types"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index a3c05e829..63cdbc3f1 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -33,7 +33,7 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import type { MeasurementType, Prisma } from "@/generated/prisma/client"; -import { WHOOP_RECOVERY_FIELD_TAGS } from "./client"; +import { WHOOP_RECOVERY_FIELD_TAGS } from "./map"; import { WHOOP_RECOVERY_SYNC_QUEUE, WHOOP_SLEEP_SYNC_QUEUE, diff --git a/src/lib/whoop/wire-types.ts b/src/lib/whoop/wire-types.ts new file mode 100644 index 000000000..2e575e1e7 --- /dev/null +++ b/src/lib/whoop/wire-types.ts @@ -0,0 +1,124 @@ +/** + * The WHOOP API v2 wire shapes HealthLog reads: the collection envelope and + * one interface per record. Shared by the endpoint fetchers (`./client.ts`) + * and the mappers (`./map.ts`). + */ +/** WHOOP paginated collection envelope: `{ records, next_token }`. */ +export interface WhoopCollection { + records: T[]; + next_token?: string | null; +} + +/** Recovery record. `score` is null until WHOOP finishes scoring. */ +export interface WhoopRecovery { + cycle_id: number; + sleep_id: string; + user_id: number; + created_at: string; + updated_at: string; + score_state: string; + score: { + user_calibrating: boolean; + recovery_score: number; + resting_heart_rate: number; + hrv_rmssd_milli: number; + spo2_percentage?: number; + skin_temp_celsius?: number; + } | null; +} + +/** Sleep activity record. */ +export interface WhoopSleep { + id: string; + /** + * The cycle this sleep belongs to. A v2 recovery is read through its cycle, + * and the recovery webhook carries the SLEEP id, so the targeted recovery + * refresh resolves sleep -> cycle -> recovery through this field. + */ + cycle_id?: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + nap: boolean; + score_state: string; + score: { + stage_summary: { + total_in_bed_time_milli: number; + total_awake_time_milli: number; + total_light_sleep_time_milli: number; + total_slow_wave_sleep_time_milli: number; + total_rem_sleep_time_milli: number; + // Per-night sleep disturbance tally. Optional: older or still-scoring + // records may omit it. + disturbance_count?: number; + }; + sleep_needed: { + baseline_milli: number; + need_from_sleep_debt_milli: number; + need_from_recent_strain_milli: number; + need_from_recent_nap_milli: number; + }; + respiratory_rate?: number; + sleep_performance_percentage?: number; + sleep_consistency_percentage?: number; + sleep_efficiency_percentage?: number; + } | null; +} + +/** Physiological cycle (day) record. `id` is an int64. */ +export interface WhoopCycle { + id: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end?: string | null; + score_state: string; + score: { + strain: number; + kilojoule: number; + average_heart_rate: number; + max_heart_rate: number; + } | null; +} + +/** Workout activity record. */ +export interface WhoopWorkout { + id: string; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + sport_id?: number; + sport_name?: string; + score_state: string; + score: { + strain: number; + average_heart_rate: number; + max_heart_rate: number; + kilojoule: number; + percent_recorded: number; + distance_meter?: number; + altitude_gain_meter?: number; + altitude_change_meter?: number; + zone_durations?: Record; + } | null; +} + +/** Body-measurement object (single, not paginated). */ +export interface WhoopBodyMeasurement { + height_meter?: number; + weight_kilogram?: number; + max_heart_rate?: number; +} + +/** Basic profile object (single, not paginated). */ +export interface WhoopProfile { + user_id: number; + email?: string; + first_name?: string; + last_name?: string; +} diff --git a/tests/integration/google-health-backfill-multi-year.test.ts b/tests/integration/google-health-backfill-multi-year.test.ts index 40869dace..b45003c83 100644 --- a/tests/integration/google-health-backfill-multi-year.test.ts +++ b/tests/integration/google-health-backfill-multi-year.test.ts @@ -362,7 +362,7 @@ describe(`Google Health backfill over ${DAYS} days of heart rate (#1220)`, () => const { handleGoogleHealthSync } = await import("@/lib/jobs/reminder/google-health-sync"); const { GOOGLE_HEALTH_TRANSIENT_RETRY } = - await import("@/lib/google-health/client"); + await import("@/lib/google-health/request"); GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; // A server error and a lost request in the first slice, and a rate limit @@ -525,7 +525,7 @@ describe(`Google Health backfill over ${DAYS} days of heart rate (#1220)`, () => const { handleGoogleHealthSync } = await import("@/lib/jobs/reminder/google-health-sync"); const { GOOGLE_HEALTH_TRANSIENT_RETRY } = - await import("@/lib/google-health/client"); + await import("@/lib/google-health/request"); GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; const prisma = getPrismaClient(); diff --git a/tests/integration/google-health-backfill-rate-limit.test.ts b/tests/integration/google-health-backfill-rate-limit.test.ts index 3ddb049fa..457a00d0c 100644 --- a/tests/integration/google-health-backfill-rate-limit.test.ts +++ b/tests/integration/google-health-backfill-rate-limit.test.ts @@ -464,7 +464,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () // A slice whose every heart-rate request fails moves nothing: it is // stuck, says where, and queues nothing. const { GOOGLE_HEALTH_TRANSIENT_RETRY } = - await import("@/lib/google-health/client"); + await import("@/lib/google-health/request"); const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs; GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; fake.failHeartRate = true; diff --git a/tests/integration/google-health-dense-backfill-memory.test.ts b/tests/integration/google-health-dense-backfill-memory.test.ts index 31414108e..51af6c4b4 100644 --- a/tests/integration/google-health-dense-backfill-memory.test.ts +++ b/tests/integration/google-health-dense-backfill-memory.test.ts @@ -228,8 +228,9 @@ describe("Google Health backfill over a dense heart-rate stream (#1023)", () => }, 900_000); it("the same pages collected into one array outgrow the budget", async () => { - const { fetchDataPoints, GOOGLE_HEALTH_DATA_TYPES } = - await import("@/lib/google-health/client"); + const { fetchDataPoints } = await import("@/lib/google-health/data-points"); + const { GOOGLE_HEALTH_DATA_TYPES } = + await import("@/lib/google-health/mappers"); let held: unknown[] = []; const { growth, samples } = await growthPastFirstQuarter(async () => { held = await fetchDataPoints( diff --git a/tests/integration/google-health-workout-terminal.test.ts b/tests/integration/google-health-workout-terminal.test.ts index f3cc5189e..d3cb65c78 100644 --- a/tests/integration/google-health-workout-terminal.test.ts +++ b/tests/integration/google-health-workout-terminal.test.ts @@ -14,12 +14,15 @@ const mocks = vi.hoisted(() => ({ noteHardFailure: vi.fn(), })); -vi.mock("@/lib/google-health/client", () => ({ +vi.mock("@/lib/google-health/data-points", () => ({ GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 25, + fetchDataPoints: mocks.fetchDataPoints, +})); + +vi.mock("@/lib/google-health/mappers", () => ({ GOOGLE_HEALTH_DATA_TYPES: { exercise: { key: "exercise", dataType: "exercise" }, }, - fetchDataPoints: mocks.fetchDataPoints, mapWorkout: mocks.mapWorkout, })); diff --git a/tests/integration/strava-sync.test.ts b/tests/integration/strava-sync.test.ts index 18d7494ad..2e4c4df30 100644 --- a/tests/integration/strava-sync.test.ts +++ b/tests/integration/strava-sync.test.ts @@ -30,7 +30,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StravaDetailedActivity } from "@/lib/strava/client"; +import type { StravaDetailedActivity } from "@/lib/strava/wire-types"; import type { IntegrationStatus, User } from "@/generated/prisma/client"; import { decrypt, encrypt } from "@/lib/crypto"; import { recordSyncFailure } from "@/lib/integrations/status";