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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:30:10 +02:00
1 parent e626f2123f
commit 7e54b6be81
78 files changed
+3651 -3489

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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";
@@ -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");
@@ -32,7 +32,7 @@ import {
googleHealthSliceBudget,
runWithGoogleHealthClientOutcome,
runWithGoogleHealthStop,
} from "../client";
} from "@/lib/google-health/budget";
beforeEach(() => {
vi.clearAllMocks();
@@ -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";
@@ -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 {
@@ -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";
@@ -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", () => {
@@ -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";
@@ -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 {
@@ -16,11 +16,11 @@ const { fetchDailyRollUpMock, fetchDataPointsMock, handleErrorMock } =
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return {
...actual,
fetchDailyRollUp: fetchDailyRollUpMock,
fetchDataPoints: fetchDataPointsMock,
};
return { ...actual, fetchDailyRollUp: fetchDailyRollUpMock };
});
vi.mock("../data-points", async (importOriginal) => {
const actual = await importOriginal<typeof import("../data-points")>();
return { ...actual, fetchDataPoints: fetchDataPointsMock };
});
vi.mock("../sync-core", () => ({
getValidToken: vi.fn(async () => ({
@@ -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;
@@ -22,8 +22,8 @@ const { fetchDataPointsMock, handleErrorMock, mapWeightMock } = vi.hoisted(
}),
);
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
vi.mock("../data-points", async (importOriginal) => {
const actual = await importOriginal<typeof import("../data-points")>();
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<typeof import("../mappers")>();
return { ...actual, mapWeight: mapWeightMock };
});
vi.mock("../sync-core", () => ({
getValidToken: vi.fn(async () => ({
@@ -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,
}));
@@ -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(() => {
@@ -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", () => ({
@@ -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 {
+278
View File
@@ -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<GoogleHealthClientOutcome>();
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<GoogleHealthClientOutcome["reasonCode"], null>,
): 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<T>(
fn: () => Promise<T>,
): 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<GoogleHealthWalkLimits>();
/** 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<T>(
shouldStop: () => boolean,
fn: () => Promise<T>,
slice?: GoogleHealthSliceBudget,
): Promise<T> {
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);
}
File diff suppressed because it is too large. Load diff
+565
View File
@@ -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<void>;
resumeFrom?: GoogleHealthResumePoint;
onResumePoint?: (point: GoogleHealthResumePoint) => void | Promise<void>;
};
/**
* 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<void>;
}
/**
* 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<GoogleHealthDataPoint[]> {
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<void | "end">,
): Promise<void> {
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<void> => {
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<GoogleHealthDataPointPage>(
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);
}
+390
View File
@@ -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<string>;
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface GoogleHealthDataResponse<T> {
status: number;
json: T | null;
verdict: ReturnType<typeof classifyGoogleHealthResponse>;
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<T>(
token: GoogleHealthAccessToken,
label: string,
send: (accessToken: string) => Promise<Response>,
expectedFallbackNote: (
status: number,
apiErrorDetail: string | undefined,
) => string | undefined = () => undefined,
): Promise<GoogleHealthDataResponse<T>> {
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 };
}
}
+4 -4
View File
@@ -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,
+3 -3
View File
@@ -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,
+7 -5
View File
@@ -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,
+2 -3
View File
@@ -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,
+4 -2
View File
@@ -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,
+1 -1
View File
@@ -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";
@@ -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,
@@ -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(() => ({
+1 -1
View File
@@ -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";
+7 -3
View File
@@ -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,
+3 -1
View File
@@ -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";
@@ -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 {
+20 -740
View File
@@ -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<T> {
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<MappedMeasurement["sleepStage"]>
> = {
"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:<i>` 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<string> = new Set([
"rest",
"deleted",
]);
/** True for an Oura sleep period the person rejected or deleted. */
export function isRetiredOuraSleepPeriod(s: Pick<OuraSleep, "type">): 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:<day>: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<string, number> = {
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:<ISO>`): 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<string>();
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);
}
+2 -4
View File
@@ -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
+556
View File
@@ -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<MappedMeasurement["sleepStage"]>
> = {
"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:<i>` 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<string> = new Set([
"rest",
"deleted",
]);
/** True for an Oura sleep period the person rejected or deleted. */
export function isRetiredOuraSleepPeriod(s: Pick<OuraSleep, "type">): 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:<day>: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<string, number> = {
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:<ISO>`): 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<string>();
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);
}
+3 -6
View File
@@ -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;
+1 -1
View File
@@ -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 {
+5 -5
View File
@@ -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",
+201
View File
@@ -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<T> {
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;
}
@@ -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 }>) {
+6 -2
View File
@@ -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";
+3 -1
View File
@@ -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";
+4 -3
View File
@@ -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
@@ -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,
+17 -623
View File
@@ -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<string, number>;
}
/** 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<T> {
records: T[];
}
@@ -281,385 +229,6 @@ export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
);
}
// ─── 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 = <resource>:<date>:<fieldTag>` unless
* the mapper supplies its own full `externalId` (the per-segment sleep rows
* need an index in the key). */
export interface MappedMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
fieldTag: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
/**
* Full externalId for rows the mapper keys itself rather than letting the
* sync layer build `<resource>:<date>:<fieldTag>`. The reconstructed sleep
* segments (one row per synthetic span) carry an index so the several rows of
* one stage stay distinct under `userId_type_source_externalId`. When set the
* sync layer uses it verbatim.
*/
externalId?: string;
/**
* 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<string, unknown>;
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,
};
}
+536
View File
@@ -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 = <resource>:<date>:<fieldTag>` unless
* the mapper supplies its own full `externalId` (the per-segment sleep rows
* need an index in the key). */
export interface MappedMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
fieldTag: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
/**
* Full externalId for rows the mapper keys itself rather than letting the
* sync layer build `<resource>:<date>:<fieldTag>`. The reconstructed sleep
* segments (one row per synthetic span) carry an index so the several rows of
* one stage stay distinct under `userId_type_source_externalId`. When set the
* sync layer uses it verbatim.
*/
externalId?: string;
/**
* 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<string, unknown>;
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,
};
}
+2 -1
View File
@@ -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,
+3 -1
View File
@@ -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,
+103
View File
@@ -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<string, number>;
}
/** 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;
}
+2 -3
View File
@@ -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 = {
@@ -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";
+10 -184
View File
@@ -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"
);
}
+152
View File
@@ -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"
);
}
+3 -8
View File
@@ -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 {
+37
View File
@@ -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;
}
+7 -3
View File
@@ -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";
+2 -5
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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 = {
+1 -1
View File
@@ -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>): WhoopWorkout {
return {
@@ -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(() => {
+19 -563
View File
@@ -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<T> {
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<string, number>;
} | 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<WhoopProfile> {
"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` (`<resource-uuid>:<fieldTag>`) 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 `<resource-uuid>:<fieldTag>`. 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
* `<sleep_id>:<fieldTag>` and share the `<sleep_id>:` 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",
},
};
+441
View File
@@ -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` (`<resource-uuid>:<fieldTag>`) 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 `<resource-uuid>:<fieldTag>`. 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
* `<sleep_id>:<fieldTag>` and share the `<sleep_id>:` 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",
},
};
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -5,7 +5,8 @@
*
* Cycle id is an int64 (not a UUID); externalId = `cycle:<id>:<fieldTag>`.
*/
import { fetchCycles, mapCycle } from "./client";
import { fetchCycles } from "./client";
import { mapCycle } from "./map";
import {
getValidToken,
incrementalStart,
+1 -1
View File
@@ -13,8 +13,8 @@ import {
fetchRecoveries,
fetchRecoveryByCycleId,
fetchSleepById,
mapRecovery,
} from "./client";
import { mapRecovery } from "./map";
import {
getValidToken,
incrementalStart,
+2 -1
View File
@@ -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,
+3 -6
View File
@@ -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,
+1 -1
View File
@@ -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,
+124
View File
@@ -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<T> {
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<string, number>;
} | 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;
}
@@ -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();
@@ -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;
@@ -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(
@@ -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,
}));
+1 -1
View File
@@ -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";